mirror of
https://github.com/logto-io/logto.git
synced 2025-02-03 21:48:55 -05:00
feat: aliyun direct mail connector (#175)
* feat: aliyun direct mail * fix: email * fix: pr fix * refactor: use got instead of axios * refactor: move aliyun to utilities * fix: pr * fix: pr * fix: pr
This commit is contained in:
parent
a77149bb8b
commit
49581d924e
7 changed files with 283 additions and 1 deletions
|
@ -1,6 +1,13 @@
|
||||||
import { ConnectorType } from '@logto/schemas';
|
import { ConnectorType } from '@logto/schemas';
|
||||||
|
|
||||||
import { ConnectorMetadata } from '../types';
|
import {
|
||||||
|
ConnectorError,
|
||||||
|
ConnectorMetadata,
|
||||||
|
EmailMessageTypes,
|
||||||
|
EmailSendMessageFunction,
|
||||||
|
} from '../types';
|
||||||
|
import { getConnectorConfig } from '../utilities';
|
||||||
|
import { singleSendMail } from './single-send-mail';
|
||||||
|
|
||||||
export const metadata: ConnectorMetadata = {
|
export const metadata: ConnectorMetadata = {
|
||||||
id: 'aliyun-dm',
|
id: 'aliyun-dm',
|
||||||
|
@ -16,3 +23,43 @@ export const metadata: ConnectorMetadata = {
|
||||||
'邮件推送(DirectMail)是款简单高效的电子邮件群发服务,构建在阿里云基础之上,帮您快速、精准地实现事务邮件、通知邮件和批量邮件的发送。',
|
'邮件推送(DirectMail)是款简单高效的电子邮件群发服务,构建在阿里云基础之上,帮您快速、精准地实现事务邮件、通知邮件和批量邮件的发送。',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
type: keyof EmailMessageTypes;
|
||||||
|
subject: string;
|
||||||
|
content: string; // With variable {{code}}, support HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AliyunDmConfig {
|
||||||
|
accessKeyId: string;
|
||||||
|
accessKeySecret: string;
|
||||||
|
accountName: string;
|
||||||
|
fromAlias?: string;
|
||||||
|
templates: Template[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendMessage: EmailSendMessageFunction = async (address, type, data) => {
|
||||||
|
const config: AliyunDmConfig = await getConnectorConfig<AliyunDmConfig>(
|
||||||
|
metadata.id,
|
||||||
|
metadata.type
|
||||||
|
);
|
||||||
|
const template = config.templates.find((template) => template.type === type);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new ConnectorError(`Can not find template for type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return singleSendMail(
|
||||||
|
{
|
||||||
|
AccessKeyId: config.accessKeyId,
|
||||||
|
AccountName: config.accountName,
|
||||||
|
ReplyToAddress: 'false',
|
||||||
|
AddressType: '1',
|
||||||
|
ToAddress: address,
|
||||||
|
FromAlias: config.fromAlias,
|
||||||
|
Subject: template.subject,
|
||||||
|
HtmlBody: template.content.replaceAll('{{code}}', data.code),
|
||||||
|
},
|
||||||
|
config.accessKeySecret
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { request } from '../utilities/aliyun';
|
||||||
|
import { singleSendMail } from './single-send-mail';
|
||||||
|
|
||||||
|
jest.mock('../utilities/aliyun');
|
||||||
|
|
||||||
|
describe('singleSendMail', () => {
|
||||||
|
it('should call request with action SingleSendMail', async () => {
|
||||||
|
await singleSendMail(
|
||||||
|
{
|
||||||
|
AccessKeyId: '<access-key-id>',
|
||||||
|
AccountName: 'noreply@example.com',
|
||||||
|
ReplyToAddress: 'false',
|
||||||
|
AddressType: '1',
|
||||||
|
ToAddress: 'user@example.com',
|
||||||
|
FromAlias: 'CompanyName',
|
||||||
|
Subject: 'test',
|
||||||
|
HtmlBody: 'test from logto',
|
||||||
|
},
|
||||||
|
'<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');
|
||||||
|
});
|
||||||
|
});
|
35
packages/core/src/connectors/aliyun-dm/single-send-mail.ts
Normal file
35
packages/core/src/connectors/aliyun-dm/single-send-mail.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { PublicParameters, request } from '../utilities/aliyun';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @doc https://help.aliyun.com/document_detail/29444.html
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
interface SingleSendMail {
|
||||||
|
AccountName: string;
|
||||||
|
AddressType: '0' | '1';
|
||||||
|
ReplyToAddress: 'true' | 'false';
|
||||||
|
Subject: string;
|
||||||
|
ToAddress: string;
|
||||||
|
ClickTrace?: '0' | '1';
|
||||||
|
FromAlias?: string;
|
||||||
|
HtmlBody?: string;
|
||||||
|
TagName?: string;
|
||||||
|
TextBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Endpoint = 'https://dm.aliyuncs.com/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @doc https://help.aliyun.com/document_detail/29444.html
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const singleSendMail = async (
|
||||||
|
parameters: PublicParameters & SingleSendMail,
|
||||||
|
accessKeySecret: string
|
||||||
|
) => {
|
||||||
|
return request<{ RequestId: string; EnvId: string }>(
|
||||||
|
Endpoint,
|
||||||
|
{ Action: 'SingleSendMail', ...parameters },
|
||||||
|
accessKeySecret
|
||||||
|
);
|
||||||
|
};
|
|
@ -12,4 +12,25 @@ export interface ConnectorMetadata {
|
||||||
// The name `Connector` is used for database, use `ConnectorInstance` to avoid confusing.
|
// The name `Connector` is used for database, use `ConnectorInstance` to avoid confusing.
|
||||||
export interface ConnectorInstance {
|
export interface ConnectorInstance {
|
||||||
metadata: ConnectorMetadata;
|
metadata: ConnectorMetadata;
|
||||||
|
sendMessage: EmailSendMessageFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailMessageTypes {
|
||||||
|
SignIn: {
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
Register: {
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
ForgotPassword: {
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailSendMessageFunction<T = unknown> = (
|
||||||
|
address: string,
|
||||||
|
type: keyof EmailMessageTypes,
|
||||||
|
payload: EmailMessageTypes[typeof type]
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export class ConnectorError extends Error {}
|
||||||
|
|
59
packages/core/src/connectors/utilities/aliyun.test.ts
Normal file
59
packages/core/src/connectors/utilities/aliyun.test.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
|
import { getSignature, request } from './aliyun';
|
||||||
|
|
||||||
|
jest.mock('got');
|
||||||
|
|
||||||
|
describe('getSignature', () => {
|
||||||
|
it('should get valid signature', () => {
|
||||||
|
const parameters = {
|
||||||
|
AccessKeyId: 'testid',
|
||||||
|
AccountName: "<a%b'>",
|
||||||
|
Action: 'SingleSendMail',
|
||||||
|
AddressType: '1',
|
||||||
|
Format: 'XML',
|
||||||
|
HtmlBody: '4',
|
||||||
|
RegionId: 'cn-hangzhou',
|
||||||
|
ReplyToAddress: 'true',
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
SignatureNonce: 'c1b2c332-4cfb-4a0f-b8cc-ebe622aa0a5c',
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
Subject: '3',
|
||||||
|
TagName: '2',
|
||||||
|
Timestamp: '2016-10-20T06:27:56Z',
|
||||||
|
ToAddress: '1@test.com',
|
||||||
|
Version: '2015-11-23',
|
||||||
|
};
|
||||||
|
const signature = getSignature(parameters, 'testsecret', 'POST');
|
||||||
|
expect(signature).toEqual('llJfXJjBW3OacrVgxxsITgYaYm0=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request', () => {
|
||||||
|
it('should call axios.post with extended params', async () => {
|
||||||
|
const parameters = {
|
||||||
|
AccessKeyId: 'testid',
|
||||||
|
AccountName: "<a%b'>",
|
||||||
|
Action: 'SingleSendMail',
|
||||||
|
AddressType: '1',
|
||||||
|
Format: 'XML',
|
||||||
|
HtmlBody: '4',
|
||||||
|
RegionId: 'cn-hangzhou',
|
||||||
|
ReplyToAddress: 'true',
|
||||||
|
Subject: '3',
|
||||||
|
TagName: '2',
|
||||||
|
ToAddress: '1@test.com',
|
||||||
|
Version: '2015-11-23',
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
};
|
||||||
|
await request('test-endpoint', 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();
|
||||||
|
});
|
||||||
|
});
|
94
packages/core/src/connectors/utilities/aliyun.ts
Normal file
94
packages/core/src/connectors/utilities/aliyun.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
|
||||||
|
import { has } from '@silverhand/essentials';
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
|
import { ConnectorError } from '../types';
|
||||||
|
|
||||||
|
// Aliyun has special excape 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)
|
||||||
|
.slice()
|
||||||
|
.sort()
|
||||||
|
.map((key) => {
|
||||||
|
const value = parameters[key];
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new ConnectorError('Invalid value');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${escaper(key)}=${escaper(value)}`;
|
||||||
|
})
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const stringToSign = `${method.toUpperCase()}&${escaper('/')}&${escaper(canonicalizedQuery)}`;
|
||||||
|
return createHmac('sha1', `${secret}&`).update(stringToSign).digest('base64');
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PublicParameters {
|
||||||
|
AccessKeyId: string;
|
||||||
|
RegionId?: string;
|
||||||
|
Format?: string;
|
||||||
|
Version?: string;
|
||||||
|
SignatureMethod?: string;
|
||||||
|
Timestamp?: string;
|
||||||
|
Signature?: string;
|
||||||
|
SignatureVersion?: string;
|
||||||
|
SignatureNonce?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonParameters = {
|
||||||
|
Version: '2015-11-23',
|
||||||
|
Format: 'json',
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const request = async <T>(
|
||||||
|
url: string,
|
||||||
|
parameters: PublicParameters & Record<string, string>,
|
||||||
|
accessKeySecret: string
|
||||||
|
) => {
|
||||||
|
const finalParameters: Record<string, string> = {
|
||||||
|
...commonParameters,
|
||||||
|
...parameters,
|
||||||
|
SignatureNonce: String(Math.random()),
|
||||||
|
Timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const signature = getSignature(finalParameters, accessKeySecret, 'POST');
|
||||||
|
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
for (const key in finalParameters) {
|
||||||
|
if (has(finalParameters, key)) {
|
||||||
|
const value = finalParameters[key];
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new ConnectorError('Invalid value');
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.append('Signature', signature);
|
||||||
|
return got.post<T>({
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
form: payload,
|
||||||
|
});
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue