mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(core): wrap aliyun direct mail connector (#660)
* feat(core): wrap aliyun direct mail connector * feat(core): remove copyfiles and reorganize files
This commit is contained in:
parent
72a78cb562
commit
54b62094c8
23 changed files with 576 additions and 16 deletions
|
@ -1,5 +1,7 @@
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
"appId": "<app-id>",
|
"appId": "<app-id>",
|
||||||
"signType": "<signing-algorithm>",
|
"signType": "<signing-algorithm>",
|
||||||
"privateKey": "<private-key>"
|
"privateKey": "<private-key>"
|
||||||
}
|
}
|
||||||
|
```
|
|
@ -2,18 +2,22 @@
|
||||||
"name": "@logto/connector-alipay",
|
"name": "@logto/connector-alipay",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Alipay implementation.",
|
"description": "Alipay implementation.",
|
||||||
"main": "lib/index.js",
|
"main": "./lib/index.js",
|
||||||
"author": "Logto Team",
|
"exports": "./lib/index.js",
|
||||||
|
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
|
"files": [
|
||||||
|
"lib",
|
||||||
|
"docs"
|
||||||
|
],
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"precommit": "lint-staged",
|
"precommit": "lint-staged",
|
||||||
"copyfiles": "copyfiles -u 1 src/**/*.md lib",
|
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
|
||||||
"build": "rm -rf lib/ && tsc -p tsconfig.build.json && pnpm run copyfiles",
|
|
||||||
"lint": "eslint --ext .ts src",
|
"lint": "eslint --ext .ts src",
|
||||||
"lint:report": "pnpm lint -- --format json --output-file report.json",
|
"lint:report": "pnpm lint -- --format json --output-file report.json",
|
||||||
"dev": "rm -rf lib/ && pnpm run copyfiles && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
|
"dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:coverage": "jest --coverage --silent",
|
"test:coverage": "jest --coverage --silent",
|
||||||
"prepack": "pnpm build"
|
"prepack": "pnpm build"
|
||||||
|
@ -39,7 +43,6 @@
|
||||||
"@types/lodash.pick": "^4.4.6",
|
"@types/lodash.pick": "^4.4.6",
|
||||||
"@types/node": "^16.3.1",
|
"@types/node": "^16.3.1",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"copyfiles": "^2.4.1",
|
|
||||||
"eslint": "^8.10.0",
|
"eslint": "^8.10.0",
|
||||||
"jest": "^27.5.1",
|
"jest": "^27.5.1",
|
||||||
"jest-matcher-specific-error": "^1.0.0",
|
"jest-matcher-specific-error": "^1.0.0",
|
||||||
|
|
|
@ -17,8 +17,8 @@ export const alipaySigningAlgorithms = ['RSA', 'RSA2'] as const;
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/prefer-module
|
// eslint-disable-next-line unicorn/prefer-module
|
||||||
const currentPath = __dirname;
|
const currentPath = __dirname;
|
||||||
const pathToReadmeFile = path.join(currentPath, 'README.md');
|
const pathToReadmeFile = path.join(currentPath, '..', 'README.md');
|
||||||
const pathToConfigTemplate = path.join(currentPath, 'config-template.md');
|
const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md');
|
||||||
const readmeContentFallback = 'Please check README.md file directory.';
|
const readmeContentFallback = 'Please check README.md file directory.';
|
||||||
const configTemplateFallback = 'Please check config-template.md file directory.';
|
const configTemplateFallback = 'Please check config-template.md file directory.';
|
||||||
|
|
||||||
|
@ -29,7 +29,6 @@ export const defaultMetadata: ConnectorMetadata = {
|
||||||
en: 'Sign In with Alipay',
|
en: 'Sign In with Alipay',
|
||||||
'zh-CN': '支付宝登录',
|
'zh-CN': '支付宝登录',
|
||||||
},
|
},
|
||||||
// TODO: add the real logo URL (LOG-1823)
|
|
||||||
logo: './logo.png',
|
logo: './logo.png',
|
||||||
description: {
|
description: {
|
||||||
en: 'Sign In with Alipay',
|
en: 'Sign In with Alipay',
|
||||||
|
|
|
@ -74,7 +74,6 @@ describe('getAccessToken', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await alipayMethods.getAccessToken('code');
|
const response = await alipayMethods.getAccessToken('code');
|
||||||
console.log(response);
|
|
||||||
const { accessToken } = response;
|
const { accessToken } = response;
|
||||||
expect(accessToken).toEqual('access_token');
|
expect(accessToken).toEqual('access_token');
|
||||||
});
|
});
|
||||||
|
|
2
packages/connector-aliyun-dm/README.md
Normal file
2
packages/connector-aliyun-dm/README.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
### Aliyun DM README
|
||||||
|
placeholder
|
25
packages/connector-aliyun-dm/docs/config-template.md
Normal file
25
packages/connector-aliyun-dm/docs/config-template.md
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessKeyId": "<access-key-id>",
|
||||||
|
"accessKeySecret": "<access-key-secret>",
|
||||||
|
"accountName": "<verified-account-name>",
|
||||||
|
"fromAlias": "<connector-alias>",
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"usageType": "SIGN_IN",
|
||||||
|
"subject": "<sign-in-template-subject>",
|
||||||
|
"content": "<sign-in-template-content>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"usageType": "REGISTER",
|
||||||
|
"subject": "<register-template-subject>",
|
||||||
|
"content": "<register-template-content>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"usageType": "TEST",
|
||||||
|
"subject": "<test-template-subject>",
|
||||||
|
"content": "<test-template-content>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
8
packages/connector-aliyun-dm/jest.config.ts
Normal file
8
packages/connector-aliyun-dm/jest.config.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { Config, merge } from '@logto/jest-config';
|
||||||
|
|
||||||
|
const config: Config.InitialOptions = merge({
|
||||||
|
testEnvironment: 'node',
|
||||||
|
setupFilesAfterEnv: ['jest-matcher-specific-error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
55
packages/connector-aliyun-dm/package.json
Normal file
55
packages/connector-aliyun-dm/package.json
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"name": "@logto/connector-aliyun-dm",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Aliyun DM connector implementation.",
|
||||||
|
"main": "./lib/index.js",
|
||||||
|
"exports": "./lib/index.js",
|
||||||
|
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"files": [
|
||||||
|
"lib",
|
||||||
|
"docs"
|
||||||
|
],
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"preinstall": "npx only-allow pnpm",
|
||||||
|
"precommit": "lint-staged",
|
||||||
|
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
|
||||||
|
"lint": "eslint --ext .ts src",
|
||||||
|
"lint:report": "pnpm lint -- --format json --output-file report.json",
|
||||||
|
"dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
|
||||||
|
"test": "jest",
|
||||||
|
"test:coverage": "jest --coverage --silent",
|
||||||
|
"prepack": "pnpm build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@logto/connector-types": "^0.1.0",
|
||||||
|
"@logto/jest-config": "^0.1.0",
|
||||||
|
"@logto/shared": "^0.1.0",
|
||||||
|
"@silverhand/essentials": "^1.1.0",
|
||||||
|
"got": "^11.8.2",
|
||||||
|
"zod": "^3.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@jest/types": "^27.5.1",
|
||||||
|
"@silverhand/eslint-config": "^0.10.2",
|
||||||
|
"@silverhand/ts-config": "^0.10.2",
|
||||||
|
"@types/jest": "^27.4.1",
|
||||||
|
"@types/node": "^16.3.1",
|
||||||
|
"eslint": "^8.10.0",
|
||||||
|
"jest": "^27.5.1",
|
||||||
|
"jest-matcher-specific-error": "^1.0.0",
|
||||||
|
"lint-staged": "^11.1.1",
|
||||||
|
"prettier": "^2.3.2",
|
||||||
|
"ts-jest": "^27.1.1",
|
||||||
|
"tsc-watch": "^4.4.0",
|
||||||
|
"typescript": "^4.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^16.0.0"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "@silverhand"
|
||||||
|
},
|
||||||
|
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||||
|
}
|
53
packages/connector-aliyun-dm/src/constant.ts
Normal file
53
packages/connector-aliyun-dm/src/constant.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
|
||||||
|
import { getFileContents } from '@logto/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @doc https://help.aliyun.com/document_detail/29444.html
|
||||||
|
*/
|
||||||
|
export interface SingleSendMail {
|
||||||
|
AccountName: string;
|
||||||
|
AddressType: '0' | '1';
|
||||||
|
ClickTrace?: '0' | '1';
|
||||||
|
FromAlias?: string;
|
||||||
|
HtmlBody?: string;
|
||||||
|
ReplyToAddress: 'true' | 'false';
|
||||||
|
Subject: string;
|
||||||
|
TagName?: string;
|
||||||
|
TextBody?: string;
|
||||||
|
ToAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const endpoint = 'https://dm.aliyuncs.com/';
|
||||||
|
|
||||||
|
export const staticConfigs = {
|
||||||
|
Format: 'json',
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
Version: '2015-11-23',
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/prefer-module
|
||||||
|
const currentPath = __dirname;
|
||||||
|
const pathToReadmeFile = path.join(currentPath, '..', 'README.md');
|
||||||
|
const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md');
|
||||||
|
const readmeContentFallback = 'Please check README.md file directory.';
|
||||||
|
const configTemplateFallback = 'Please check config-template.md file directory.';
|
||||||
|
|
||||||
|
export const defaultMetadata: ConnectorMetadata = {
|
||||||
|
id: 'aliyun-dm',
|
||||||
|
type: ConnectorType.Email,
|
||||||
|
name: {
|
||||||
|
en: 'Aliyun Direct Mail',
|
||||||
|
'zh-CN': '阿里云邮件推送',
|
||||||
|
},
|
||||||
|
logo: './logo.png',
|
||||||
|
description: {
|
||||||
|
en: 'A simple and efficient email service to help you send transactional notifications and batch email.',
|
||||||
|
'zh-CN':
|
||||||
|
'邮件推送(DirectMail)是款简单高效的电子邮件群发服务,构建在阿里云基础之上,帮您快速、精准地实现事务邮件、通知邮件和批量邮件的发送。',
|
||||||
|
},
|
||||||
|
readme: getFileContents(pathToReadmeFile, readmeContentFallback),
|
||||||
|
configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback),
|
||||||
|
};
|
58
packages/connector-aliyun-dm/src/index.test.ts
Normal file
58
packages/connector-aliyun-dm/src/index.test.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { GetConnectorConfig } from '@logto/connector-types';
|
||||||
|
|
||||||
|
import { AliyunDmConnector, AliyunDmConfig } from '.';
|
||||||
|
import { mockedConfig } from './mock';
|
||||||
|
import { singleSendMail } from './single-send-mail';
|
||||||
|
|
||||||
|
const getConnectorConfig = jest.fn() as GetConnectorConfig<AliyunDmConfig>;
|
||||||
|
|
||||||
|
const aliyunDmMethods = new AliyunDmConnector(getConnectorConfig);
|
||||||
|
|
||||||
|
jest.mock('./single-send-mail');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.spyOn(aliyunDmMethods, 'getConfig').mockResolvedValue(mockedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateConfig()', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass on valid config', async () => {
|
||||||
|
await expect(
|
||||||
|
aliyunDmMethods.validateConfig({
|
||||||
|
accessKeyId: 'accessKeyId',
|
||||||
|
accessKeySecret: 'accessKeySecret',
|
||||||
|
accountName: 'accountName',
|
||||||
|
templates: [],
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if config is invalid', async () => {
|
||||||
|
await expect(aliyunDmMethods.validateConfig({})).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendMessage()', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call singleSendMail() and replace code in content', async () => {
|
||||||
|
await aliyunDmMethods.sendMessage('to@email.com', 'SignIn', { code: '1234' });
|
||||||
|
expect(singleSendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
HtmlBody: 'Your code is 1234, 1234 is your code',
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if template is missing', async () => {
|
||||||
|
await expect(
|
||||||
|
aliyunDmMethods.sendMessage('to@email.com', 'Register', { code: '1234' })
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
90
packages/connector-aliyun-dm/src/index.ts
Normal file
90
packages/connector-aliyun-dm/src/index.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import {
|
||||||
|
ConnectorError,
|
||||||
|
ConnectorErrorCodes,
|
||||||
|
ConnectorMetadata,
|
||||||
|
EmailSendMessageFunction,
|
||||||
|
ValidateConfig,
|
||||||
|
EmailConnector,
|
||||||
|
GetConnectorConfig,
|
||||||
|
} from '@logto/connector-types';
|
||||||
|
import { assert } from '@silverhand/essentials';
|
||||||
|
import { Response } from 'got';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { defaultMetadata } from './constant';
|
||||||
|
import { singleSendMail } from './single-send-mail';
|
||||||
|
import { SendEmailResponse } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UsageType here is used to specify the use case of the template, can be either
|
||||||
|
* 'Register', 'SignIn', 'ForgotPassword' or 'Test'.
|
||||||
|
*/
|
||||||
|
const templateGuard = z.object({
|
||||||
|
usageType: z.string(),
|
||||||
|
subject: z.string(),
|
||||||
|
content: z.string(), // With variable {{code}}, support HTML
|
||||||
|
});
|
||||||
|
|
||||||
|
const configGuard = z.object({
|
||||||
|
accessKeyId: z.string(),
|
||||||
|
accessKeySecret: z.string(),
|
||||||
|
accountName: z.string(),
|
||||||
|
fromAlias: z.string().optional(),
|
||||||
|
templates: z.array(templateGuard),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AliyunDmConfig = z.infer<typeof configGuard>;
|
||||||
|
|
||||||
|
export class AliyunDmConnector implements EmailConnector {
|
||||||
|
public metadata: ConnectorMetadata = defaultMetadata;
|
||||||
|
|
||||||
|
public readonly getConfig: GetConnectorConfig<AliyunDmConfig>;
|
||||||
|
|
||||||
|
constructor(getConnectorConfig: GetConnectorConfig<AliyunDmConfig>) {
|
||||||
|
this.getConfig = getConnectorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateConfig: ValidateConfig = async (config: unknown) => {
|
||||||
|
const result = configGuard.safeParse(config);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public sendMessage: EmailSendMessageFunction<Response<SendEmailResponse>> = async (
|
||||||
|
address,
|
||||||
|
type,
|
||||||
|
data
|
||||||
|
) => {
|
||||||
|
const config = await this.getConfig(this.metadata.id);
|
||||||
|
await this.validateConfig(config);
|
||||||
|
const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config;
|
||||||
|
const template = templates.find((template) => template.usageType === type);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
template,
|
||||||
|
new ConnectorError(
|
||||||
|
ConnectorErrorCodes.TemplateNotFound,
|
||||||
|
`Cannot find template for type: ${type}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return singleSendMail(
|
||||||
|
{
|
||||||
|
AccessKeyId: accessKeyId,
|
||||||
|
AccountName: accountName,
|
||||||
|
ReplyToAddress: 'false',
|
||||||
|
AddressType: '1',
|
||||||
|
ToAddress: address,
|
||||||
|
FromAlias: fromAlias,
|
||||||
|
Subject: template.subject,
|
||||||
|
HtmlBody:
|
||||||
|
typeof data.code === 'string'
|
||||||
|
? template.content.replace(/{{code}}/g, data.code)
|
||||||
|
: template.content,
|
||||||
|
},
|
||||||
|
accessKeySecret
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
29
packages/connector-aliyun-dm/src/mock.ts
Normal file
29
packages/connector-aliyun-dm/src/mock.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
export const mockedParameters = {
|
||||||
|
AccessKeyId: 'testid',
|
||||||
|
AccountName: "<a%b'>",
|
||||||
|
Action: 'SingleSendMail',
|
||||||
|
AddressType: '1',
|
||||||
|
Format: 'XML',
|
||||||
|
HtmlBody: '4',
|
||||||
|
RegionId: 'cn-hangzhou',
|
||||||
|
ReplyToAddress: 'true',
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
Subject: '3',
|
||||||
|
TagName: '2',
|
||||||
|
ToAddress: '1@test.com',
|
||||||
|
Version: '2015-11-23',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockedConfig = {
|
||||||
|
accessKeyId: 'accessKeyId',
|
||||||
|
accessKeySecret: 'accessKeySecret',
|
||||||
|
accountName: 'accountName',
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
usageType: 'SignIn',
|
||||||
|
content: 'Your code is {{code}}, {{code}} is your code',
|
||||||
|
subject: 'subject',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
26
packages/connector-aliyun-dm/src/single-send-mail.test.ts
Normal file
26
packages/connector-aliyun-dm/src/single-send-mail.test.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { singleSendMail } from './single-send-mail';
|
||||||
|
import { request } from './utils';
|
||||||
|
|
||||||
|
jest.mock('./utils');
|
||||||
|
|
||||||
|
describe('singleSendMail', () => {
|
||||||
|
it('should call request with action SingleSendMail', async () => {
|
||||||
|
await singleSendMail(
|
||||||
|
{
|
||||||
|
AccessKeyId: '<access-key-id>',
|
||||||
|
AccountName: 'noreply@example.com',
|
||||||
|
AddressType: '1',
|
||||||
|
FromAlias: 'CompanyName',
|
||||||
|
HtmlBody: 'test from logto',
|
||||||
|
ReplyToAddress: 'false',
|
||||||
|
Subject: 'test',
|
||||||
|
ToAddress: 'user@example.com',
|
||||||
|
},
|
||||||
|
'<access-key-secret>'
|
||||||
|
);
|
||||||
|
const calledData = (request as jest.MockedFunction<typeof request>).mock.calls[0];
|
||||||
|
expect(calledData).not.toBeUndefined();
|
||||||
|
const payload = calledData?.[1];
|
||||||
|
expect(payload).toHaveProperty('Action', 'SingleSendMail');
|
||||||
|
});
|
||||||
|
});
|
18
packages/connector-aliyun-dm/src/single-send-mail.ts
Normal file
18
packages/connector-aliyun-dm/src/single-send-mail.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { Response } from 'got';
|
||||||
|
|
||||||
|
import { SingleSendMail, endpoint, staticConfigs } from './constant';
|
||||||
|
import { PublicParameters, request, SendEmailResponse } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @doc https://help.aliyun.com/document_detail/29444.html
|
||||||
|
*/
|
||||||
|
export const singleSendMail = async (
|
||||||
|
parameters: PublicParameters & SingleSendMail,
|
||||||
|
accessKeySecret: string
|
||||||
|
): Promise<Response<SendEmailResponse>> => {
|
||||||
|
return request<SendEmailResponse>(
|
||||||
|
endpoint,
|
||||||
|
{ Action: 'SingleSendMail', ...staticConfigs, ...parameters },
|
||||||
|
accessKeySecret
|
||||||
|
);
|
||||||
|
};
|
32
packages/connector-aliyun-dm/src/utils.test.ts
Normal file
32
packages/connector-aliyun-dm/src/utils.test.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
|
import { mockedParameters } from './mock';
|
||||||
|
import { getSignature, request } from './utils';
|
||||||
|
|
||||||
|
jest.mock('got');
|
||||||
|
|
||||||
|
describe('getSignature', () => {
|
||||||
|
it('should get valid signature', () => {
|
||||||
|
const parameters = {
|
||||||
|
...mockedParameters,
|
||||||
|
SignatureNonce: 'c1b2c332-4cfb-4a0f-b8cc-ebe622aa0a5c',
|
||||||
|
Timestamp: '2016-10-20T06:27:56Z',
|
||||||
|
};
|
||||||
|
const signature = getSignature(parameters, 'testsecret', 'POST');
|
||||||
|
expect(signature).toEqual('llJfXJjBW3OacrVgxxsITgYaYm0=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request', () => {
|
||||||
|
it('should call axios.post with extended params', async () => {
|
||||||
|
const parameters = mockedParameters;
|
||||||
|
await request('http://test.endpoint.com', parameters, 'testsecret');
|
||||||
|
const calledData = (got.post as jest.MockedFunction<typeof got.post>).mock.calls[0];
|
||||||
|
expect(calledData).not.toBeUndefined();
|
||||||
|
const payload = calledData?.[0].form as URLSearchParams;
|
||||||
|
expect(payload.get('AccessKeyId')).toEqual('testid');
|
||||||
|
expect(payload.get('Timestamp')).not.toBeNull();
|
||||||
|
expect(payload.get('SignatureNonce')).not.toBeNull();
|
||||||
|
expect(payload.get('Signature')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
79
packages/connector-aliyun-dm/src/utils.ts
Normal file
79
packages/connector-aliyun-dm/src/utils.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
|
export type { Response } from 'got';
|
||||||
|
export type SendEmailResponse = { EnvId: string; RequestId: string };
|
||||||
|
|
||||||
|
// Aliyun has special escape rules.
|
||||||
|
// https://help.aliyun.com/document_detail/29442.html
|
||||||
|
const escaper = (string_: string) =>
|
||||||
|
encodeURIComponent(string_)
|
||||||
|
.replace(/\*/g, '%2A')
|
||||||
|
.replace(/'/g, '%27')
|
||||||
|
.replace(/!/g, '%21')
|
||||||
|
.replace(/"/g, '%22')
|
||||||
|
.replace(/\(/g, '%28')
|
||||||
|
.replace(/\)/g, '%29')
|
||||||
|
.replace(/\+/, '%2B');
|
||||||
|
|
||||||
|
export const getSignature = (
|
||||||
|
parameters: Record<string, string>,
|
||||||
|
secret: string,
|
||||||
|
method: string
|
||||||
|
) => {
|
||||||
|
const canonicalizedQuery = Object.keys(parameters)
|
||||||
|
.map((key) => {
|
||||||
|
const value = parameters[key];
|
||||||
|
|
||||||
|
return value === undefined ? '' : `${escaper(key)}=${escaper(value)}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice()
|
||||||
|
.sort()
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const stringToSign = `${method.toUpperCase()}&${escaper('/')}&${escaper(canonicalizedQuery)}`;
|
||||||
|
|
||||||
|
return createHmac('sha1', `${secret}&`).update(stringToSign).digest('base64');
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PublicParameters {
|
||||||
|
AccessKeyId: string;
|
||||||
|
Format?: string; // 'json' or 'xml', default: 'json'
|
||||||
|
RegionId?: string; // 'cn-hangzhou' | 'ap-southeast-1' | 'ap-southeast-2'
|
||||||
|
Signature?: string;
|
||||||
|
SignatureMethod?: string;
|
||||||
|
SignatureNonce?: string;
|
||||||
|
SignatureVersion?: string;
|
||||||
|
Timestamp?: string;
|
||||||
|
Version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const request = async <T>(
|
||||||
|
url: string,
|
||||||
|
parameters: PublicParameters & Record<string, string>,
|
||||||
|
accessKeySecret: string
|
||||||
|
) => {
|
||||||
|
const finalParameters: Record<string, string> = {
|
||||||
|
...parameters,
|
||||||
|
SignatureNonce: String(Math.random()),
|
||||||
|
Timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const signature = getSignature(finalParameters, accessKeySecret, 'POST');
|
||||||
|
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(finalParameters)) {
|
||||||
|
payload.append(key, value);
|
||||||
|
}
|
||||||
|
payload.append('Signature', signature);
|
||||||
|
|
||||||
|
return got.post<T>({
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
form: payload,
|
||||||
|
});
|
||||||
|
};
|
10
packages/connector-aliyun-dm/tsconfig.base.json
Normal file
10
packages/connector-aliyun-dm/tsconfig.base.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
packages/connector-aliyun-dm/tsconfig.build.json
Normal file
5
packages/connector-aliyun-dm/tsconfig.build.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.base",
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
7
packages/connector-aliyun-dm/tsconfig.json
Normal file
7
packages/connector-aliyun-dm/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "jest", "jest-matcher-specific-error"]
|
||||||
|
},
|
||||||
|
"include": ["src", "jest.config.ts"]
|
||||||
|
}
|
6
packages/connector-aliyun-dm/tsconfig.test.json
Normal file
6
packages/connector-aliyun-dm/tsconfig.test.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": false
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,17 +50,15 @@ export type EmailMessageTypes = {
|
||||||
|
|
||||||
type SmsMessageTypes = EmailMessageTypes;
|
type SmsMessageTypes = EmailMessageTypes;
|
||||||
|
|
||||||
export type SendEmailResponse = { EnvId: string; RequestId: string };
|
|
||||||
|
|
||||||
export type SendSmsResponse = { BizId: string; Code: string; Message: string; RequestId: string };
|
export type SendSmsResponse = { BizId: string; Code: string; Message: string; RequestId: string };
|
||||||
|
|
||||||
export type EmailSendMessageFunction<T = Record<string, unknown>> = (
|
export type EmailSendMessageFunction<T = unknown> = (
|
||||||
address: string,
|
address: string,
|
||||||
type: keyof EmailMessageTypes,
|
type: keyof EmailMessageTypes,
|
||||||
payload: EmailMessageTypes[typeof type]
|
payload: EmailMessageTypes[typeof type]
|
||||||
) => Promise<T>;
|
) => Promise<T>;
|
||||||
|
|
||||||
export type SmsSendMessageFunction<T = Record<string, unknown>> = (
|
export type SmsSendMessageFunction<T = unknown> = (
|
||||||
phone: string,
|
phone: string,
|
||||||
type: keyof SmsMessageTypes,
|
type: keyof SmsMessageTypes,
|
||||||
payload: SmsMessageTypes[typeof type]
|
payload: SmsMessageTypes[typeof type]
|
||||||
|
|
|
@ -32,7 +32,6 @@ importers:
|
||||||
'@types/lodash.pick': ^4.4.6
|
'@types/lodash.pick': ^4.4.6
|
||||||
'@types/node': ^16.3.1
|
'@types/node': ^16.3.1
|
||||||
'@types/supertest': ^2.0.11
|
'@types/supertest': ^2.0.11
|
||||||
copyfiles: ^2.4.1
|
|
||||||
dayjs: ^1.10.5
|
dayjs: ^1.10.5
|
||||||
eslint: ^8.10.0
|
eslint: ^8.10.0
|
||||||
got: ^11.8.2
|
got: ^11.8.2
|
||||||
|
@ -69,7 +68,6 @@ importers:
|
||||||
'@types/lodash.pick': 4.4.6
|
'@types/lodash.pick': 4.4.6
|
||||||
'@types/node': 16.11.12
|
'@types/node': 16.11.12
|
||||||
'@types/supertest': 2.0.11
|
'@types/supertest': 2.0.11
|
||||||
copyfiles: 2.4.1
|
|
||||||
eslint: 8.10.0
|
eslint: 8.10.0
|
||||||
jest: 27.5.1
|
jest: 27.5.1
|
||||||
jest-matcher-specific-error: 1.0.0
|
jest-matcher-specific-error: 1.0.0
|
||||||
|
@ -81,6 +79,49 @@ importers:
|
||||||
tsc-watch: 4.5.0_typescript@4.6.3
|
tsc-watch: 4.5.0_typescript@4.6.3
|
||||||
typescript: 4.6.3
|
typescript: 4.6.3
|
||||||
|
|
||||||
|
packages/connector-aliyun-dm:
|
||||||
|
specifiers:
|
||||||
|
'@jest/types': ^27.5.1
|
||||||
|
'@logto/connector-types': ^0.1.0
|
||||||
|
'@logto/jest-config': ^0.1.0
|
||||||
|
'@logto/shared': ^0.1.0
|
||||||
|
'@silverhand/eslint-config': ^0.10.2
|
||||||
|
'@silverhand/essentials': ^1.1.0
|
||||||
|
'@silverhand/ts-config': ^0.10.2
|
||||||
|
'@types/jest': ^27.4.1
|
||||||
|
'@types/node': ^16.3.1
|
||||||
|
eslint: ^8.10.0
|
||||||
|
got: ^11.8.2
|
||||||
|
jest: ^27.5.1
|
||||||
|
jest-matcher-specific-error: ^1.0.0
|
||||||
|
lint-staged: ^11.1.1
|
||||||
|
prettier: ^2.3.2
|
||||||
|
ts-jest: ^27.1.1
|
||||||
|
tsc-watch: ^4.4.0
|
||||||
|
typescript: ^4.6.2
|
||||||
|
zod: ^3.14.3
|
||||||
|
dependencies:
|
||||||
|
'@logto/connector-types': link:../connector-types
|
||||||
|
'@logto/jest-config': link:../jest-config
|
||||||
|
'@logto/shared': link:../shared
|
||||||
|
'@silverhand/essentials': 1.1.7
|
||||||
|
got: 11.8.3
|
||||||
|
zod: 3.14.3
|
||||||
|
devDependencies:
|
||||||
|
'@jest/types': 27.5.1
|
||||||
|
'@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709
|
||||||
|
'@silverhand/ts-config': 0.10.2_typescript@4.6.3
|
||||||
|
'@types/jest': 27.4.1
|
||||||
|
'@types/node': 16.11.12
|
||||||
|
eslint: 8.10.0
|
||||||
|
jest: 27.5.1
|
||||||
|
jest-matcher-specific-error: 1.0.0
|
||||||
|
lint-staged: 11.2.6
|
||||||
|
prettier: 2.5.1
|
||||||
|
ts-jest: 27.1.1_9985e1834e803358b7be1e6ce5ca0eea
|
||||||
|
tsc-watch: 4.6.2_typescript@4.6.3
|
||||||
|
typescript: 4.6.3
|
||||||
|
|
||||||
packages/connector-types:
|
packages/connector-types:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@jest/types': ^27.5.1
|
'@jest/types': ^27.5.1
|
||||||
|
@ -19021,6 +19062,21 @@ packages:
|
||||||
typescript: 4.6.3
|
typescript: 4.6.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/tsc-watch/4.6.2_typescript@4.6.3:
|
||||||
|
resolution: {integrity: sha512-eHWzZGkPmzXVGQKbqQgf3BFpGiZZw1jQ29ZOJeaSe8JfyUvphbd221NfXmmsJUGGPGA/nnaSS01tXipUcyxAxg==}
|
||||||
|
engines: {node: '>=8.17.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
dependencies:
|
||||||
|
cross-spawn: 7.0.3
|
||||||
|
node-cleanup: 2.1.2
|
||||||
|
ps-tree: 1.2.0
|
||||||
|
string-argv: 0.1.2
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
typescript: 4.6.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
/tsc-watch/5.0.3_typescript@4.6.2:
|
/tsc-watch/5.0.3_typescript@4.6.2:
|
||||||
resolution: {integrity: sha512-Hz2UawwELMSLOf0xHvAFc7anLeMw62cMVXr1flYmhRuOhOyOljwmb1l/O60ZwRyy1k7N1iC1mrn1QYM2zITfuw==}
|
resolution: {integrity: sha512-Hz2UawwELMSLOf0xHvAFc7anLeMw62cMVXr1flYmhRuOhOyOljwmb1l/O60ZwRyy1k7N1iC1mrn1QYM2zITfuw==}
|
||||||
engines: {node: '>=8.17.0'}
|
engines: {node: '>=8.17.0'}
|
||||||
|
|
Loading…
Reference in a new issue