Skip to content
This repository has been archived by the owner on Apr 3, 2023. It is now read-only.

feat(tencent-mail): add tencent mail connector #47

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/connector-tencent-email/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Tencent mail connector
72 changes: 72 additions & 0 deletions packages/connector-tencent-email/docs/config-template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"accessKeyId": "<access-key-id>",
"accessKeySecret": "<access-key-secret>",
"region": "<ap-hongkong|ap-singapore>",
"fromAddress": "<from-address>",
"fromName": "<from-alias>",
"replyAddress": "<reply-address>",
StringKe marked this conversation as resolved.
Show resolved Hide resolved
"templates": [
{
"fromAddress": "<from-address>",
"fromName": "<from-alias>",
"replyAddress": "<reply-address>",
"usageType": "SignIn",
"subject": "<subject>",
"templateId": "<templateId>",
"params": [
{
"name": "<tencent-cloud-template-param-name>",
"value": "code or toAddress or fromAddress or fromName or replayAddress or subject"
},
{
"name": "<tencent-cloud-template-param-name>",
"value": "code or toAddress or fromAddress or fromName or replayAddress or subject"
}
]
},
{
"usageType": "Register",
"templateId": "<templateId>",
"subject": "<subject>",
"params": [
{
"name": "code",
"value": "code"
}
]
},
{
"usageType": "ForgotPassword",
"templateId": "<templateId>",
"subject": "<subject>",
"params": [
{
"name": "code",
"value": "code"
}
]
},
{
"usageType": "Continue",
"templateId": "<templateId>",
"subject": "<subject>",
"params": [
{
"name": "code",
"value": "code"
}
]
},
{
"usageType": "Test",
"templateId": "<templateId>",
"subject": "<subject>",
"params": [
{
"name": "code",
"value": "code"
}
]
}
]
}
12 changes: 12 additions & 0 deletions packages/connector-tencent-email/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions packages/connector-tencent-email/package.extend.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "@logto/connector-tencent-email",
"version": "1.0.0-beta.15",
"description": "Tencent Email connector implementation.",
"author": "StringKe"
}
21 changes: 21 additions & 0 deletions packages/connector-tencent-email/src/constant.ts
Original file line number Diff line number Diff line change
@@ -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',
};
123 changes: 123 additions & 0 deletions packages/connector-tencent-email/src/http.ts
Original file line number Diff line number Diff line change
@@ -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');
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
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(' ');
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved

return got.post<TencentErrorResponse | TencentSuccessResponse>(`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',
});
}
88 changes: 88 additions & 0 deletions packages/connector-tencent-email/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { endpoint } from '@/constant';
import { ConnectorError, ConnectorErrorCodes, VerificationCodeType } from '@logto/connector-kit';
import nock from 'nock';

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: VerificationCodeType.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: VerificationCodeType.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: VerificationCodeType.Test,
payload: { code: '' },
})
).resolves.not.toThrow();
});
});
Loading