0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -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:
Wang Sijie 2022-01-17 13:49:29 +08:00 committed by GitHub
parent a77149bb8b
commit 49581d924e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 283 additions and 1 deletions

View file

@ -1,6 +1,13 @@
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 = {
id: 'aliyun-dm',
@ -16,3 +23,43 @@ export const metadata: ConnectorMetadata = {
'邮件推送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
);
};

View file

@ -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');
});
});

View 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
);
};

View file

@ -12,4 +12,25 @@ export interface ConnectorMetadata {
// The name `Connector` is used for database, use `ConnectorInstance` to avoid confusing.
export interface ConnectorInstance {
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 {}

View 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();
});
});

View 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,
});
};