diff --git a/packages/core/src/connectors/aliyun-dm/index.ts b/packages/core/src/connectors/aliyun-dm/index.ts index 7f5384d15..5d6d6039a 100644 --- a/packages/core/src/connectors/aliyun-dm/index.ts +++ b/packages/core/src/connectors/aliyun-dm/index.ts @@ -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( + 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 + ); +}; diff --git a/packages/core/src/connectors/aliyun-dm/single-send-mail.test.ts b/packages/core/src/connectors/aliyun-dm/single-send-mail.test.ts new file mode 100644 index 000000000..b53424068 --- /dev/null +++ b/packages/core/src/connectors/aliyun-dm/single-send-mail.test.ts @@ -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: '', + AccountName: 'noreply@example.com', + ReplyToAddress: 'false', + AddressType: '1', + ToAddress: 'user@example.com', + FromAlias: 'CompanyName', + Subject: 'test', + HtmlBody: 'test from logto', + }, + '' + ); + const calledData = (request as jest.MockedFunction).mock.calls[0]; + expect(calledData).not.toBeUndefined(); + const payload = calledData?.[1]; + expect(payload).toHaveProperty('Action', 'SingleSendMail'); + }); +}); diff --git a/packages/core/src/connectors/aliyun-dm/single-send-mail.ts b/packages/core/src/connectors/aliyun-dm/single-send-mail.ts new file mode 100644 index 000000000..63cae4ea2 --- /dev/null +++ b/packages/core/src/connectors/aliyun-dm/single-send-mail.ts @@ -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 + ); +}; diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts index 0dd1fe0cc..a21a38b83 100644 --- a/packages/core/src/connectors/types.ts +++ b/packages/core/src/connectors/types.ts @@ -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 = ( + address: string, + type: keyof EmailMessageTypes, + payload: EmailMessageTypes[typeof type] +) => Promise; + +export class ConnectorError extends Error {} diff --git a/packages/core/src/connectors/utilities/aliyun.test.ts b/packages/core/src/connectors/utilities/aliyun.test.ts new file mode 100644 index 000000000..e068d5a4c --- /dev/null +++ b/packages/core/src/connectors/utilities/aliyun.test.ts @@ -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: "", + 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: "", + 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).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(); + }); +}); diff --git a/packages/core/src/connectors/utilities/aliyun.ts b/packages/core/src/connectors/utilities/aliyun.ts new file mode 100644 index 000000000..4eab7d2a4 --- /dev/null +++ b/packages/core/src/connectors/utilities/aliyun.ts @@ -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, + 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 ( + url: string, + parameters: PublicParameters & Record, + accessKeySecret: string +) => { + const finalParameters: Record = { + ...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({ + url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + form: payload, + }); +}; diff --git a/packages/core/src/connectors/utils.ts b/packages/core/src/connectors/utilities/index.ts similarity index 100% rename from packages/core/src/connectors/utils.ts rename to packages/core/src/connectors/utilities/index.ts