0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): add smtp connector (#1131)

* feat(connector-smtp): add smtp connector

* feat(connector-smtp): fix UTs
This commit is contained in:
Darcy Ye 2022-06-17 10:52:35 +08:00 committed by GitHub
parent a424f1b1d2
commit f8710e147d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 758 additions and 87 deletions

View file

@ -0,0 +1,2 @@
### SMTP README
placeholder

View file

@ -0,0 +1,33 @@
{
"host": "<test.smtp.host>",
"port": 80,
"password": "<password>",
"username": "<username>",
"fromEmail": "<notice@test.smtp>",
"templates": [
{
"contentType": "text/plain",
"content": "This is for testing purposes only.",
"subject": "Logto Test with SMTP",
"usageType": "Test"
},
{
"contentType": "text/plain",
"content": "This is for sign-in purposes only.",
"subject": "Logto Sign In with SMTP",
"usageType": "SignIn"
},
{
"contentType": "text/plain",
"content": "This is for register purposes only.",
"subject": "Logto Register with SMTP",
"usageType": "Register"
},
{
"contentType": "text/plain",
"content": "This is for forgot-password purposes only.",
"subject": "Logto Forgot Password with SMTP",
"usageType": "ForgotPassword"
}
]
}

View file

@ -0,0 +1,7 @@
import { Config, merge } from '@silverhand/jest-config';
const config: Config.InitialOptions = merge({
testEnvironment: 'node',
});
export default config;

View file

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 10.0413C2 9.38868 2.31842 8.7771 2.85308 8.40285L11.4265 2.40142C11.7709 2.1604 12.2291 2.1604 12.5735 2.40142L21.1469 8.40285C21.6816 8.7771 22 9.38868 22 10.0413V18C22 20.2091 20.2091 22 18 22H6C3.79086 22 2 20.2091 2 18V10.0413Z" fill="#F7F8F8"/>
<path d="M2 10.0413C2 9.38868 2.31842 8.7771 2.85308 8.40285L11.4265 2.40142C11.7709 2.1604 12.2291 2.1604 12.5735 2.40142L21.1469 8.40285C21.6816 8.7771 22 9.38868 22 10.0413V18C22 20.2091 20.2091 22 18 22H6C3.79086 22 2 20.2091 2 18V10.0413Z" fill="#78767F" fill-opacity="0.02"/>
<path d="M2 10.0413C2 9.38868 2.31842 8.7771 2.85308 8.40285L11.4265 2.40142C11.7709 2.1604 12.2291 2.1604 12.5735 2.40142L21.1469 8.40285C21.6816 8.7771 22 9.38868 22 10.0413V18C22 20.2091 20.2091 22 18 22H6C3.79086 22 2 20.2091 2 18V10.0413Z" fill="#5D34F2" fill-opacity="0.16"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 6.5C11 6.22386 11.2239 6 11.5 6H12.5C12.7761 6 13 6.22386 13 6.5V9H11V6.5ZM11 15.5C11 15.7761 11.2239 16 11.5 16H12.5C12.7761 16 13 15.7761 13 15.5V13H11V15.5ZM7.5 12C7.22386 12 7 11.7761 7 11.5V10.5C7 10.2239 7.22386 10 7.5 10H10V12H7.5ZM16.5 12C16.7761 12 17 11.7761 17 11.5V10.5C17 10.2239 16.7761 10 16.5 10H14V12H16.5Z" fill="#E67EF7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.11167 8.52513C7.91641 8.32986 7.91641 8.01328 8.11167 7.81802L8.81877 7.11091C9.01404 6.91565 9.33062 6.91565 9.52588 7.11091L11.2936 8.87868L9.87943 10.2929L8.11167 8.52513ZM14.4756 14.8891C14.6709 15.0843 14.9875 15.0843 15.1827 14.8891L15.8898 14.182C16.0851 13.9867 16.0851 13.6701 15.8898 13.4749L14.1221 11.7071L12.7079 13.1213L14.4756 14.8891ZM9.52588 14.8891C9.33062 15.0843 9.01404 15.0843 8.81877 14.8891L8.11167 14.182C7.91641 13.9867 7.91641 13.6701 8.11167 13.4749L9.87943 11.7071L11.2936 13.1213L9.52588 14.8891ZM15.8898 8.52513C16.0851 8.32986 16.0851 8.01328 15.8898 7.81802L15.1827 7.11091C14.9875 6.91565 14.6709 6.91565 14.4756 7.11091L12.7079 8.87868L14.1221 10.2929L15.8898 8.52513Z" fill="#E67EF7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 15C14.2091 15 16 13.2091 16 11C16 8.79086 14.2091 7 12 7C9.79086 7 8 8.79086 8 11C8 13.2091 9.79086 15 12 15ZM12 13C13.1046 13 14 12.1046 14 11C14 9.89543 13.1046 9 12 9C10.8954 9 10 9.89543 10 11C10 12.1046 10.8954 13 12 13Z" fill="#F099FE"/>
<path d="M2 9.92066C2 9.11163 2.91069 8.63748 3.57346 9.10142L11.4265 14.5986C11.7709 14.8396 12.2291 14.8396 12.5735 14.5986L20.4265 9.10142C21.0893 8.63748 22 9.11163 22 9.92066V20C22 21.1046 21.1046 22 20 22H4C2.89543 22 2 21.1046 2 20V9.92066Z" fill="#7958FF"/>
<path d="M3.58661 22C2.67257 22 2.23761 20.8749 2.91393 20.2601L11.3273 12.6115C11.7087 12.2648 12.2913 12.2648 12.6727 12.6115L21.0861 20.2601C21.7624 20.8749 21.3274 22 20.4134 22H3.58661Z" fill="#957BFF"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,54 @@
{
"name": "@logto/connector-smtp",
"version": "0.1.0",
"description": "SMTP connector implementation.",
"main": "./lib/index.js",
"exports": "./lib/index.js",
"author": "Silverhand Inc. <contact@silverhand.io>",
"license": "MPL-2.0",
"files": [
"lib",
"docs",
"logo.svg"
],
"scripts": {
"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/shared": "^0.1.0",
"@silverhand/essentials": "^1.1.6",
"@silverhand/jest-config": "^0.14.0",
"zod": "^3.14.3",
"nodemailer": "^6.7.5"
},
"devDependencies": {
"@jest/types": "^27.5.1",
"@silverhand/eslint-config": "^0.14.0",
"@silverhand/ts-config": "^0.14.0",
"@types/jest": "^27.4.1",
"@types/node": "^16.3.1",
"@types/nodemailer": "^6.4.4",
"eslint": "^8.10.0",
"jest": "^27.5.1",
"lint-staged": "^13.0.0",
"prettier": "^2.3.2",
"ts-jest": "^27.1.1",
"tsc-watch": "^5.0.0",
"typescript": "^4.6.2"
},
"engines": {
"node": "^16.0.0"
},
"eslintConfig": {
"extends": "@silverhand"
},
"prettier": "@silverhand/eslint-config/.prettierrc"
}

View file

@ -0,0 +1,29 @@
import path from 'path';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
// 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.json');
const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.json file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'simple-mail-transfer-protocol',
target: 'smtp',
type: ConnectorType.Email,
platform: null,
name: {
en: 'SMTP',
'zh-CN': 'SMTP',
},
logo: './logo.svg',
description: {
en: 'Simple Mail Transfer Protocol.',
'zh-CN': '简单邮件传输协议。',
},
readme: getFileContents(pathToReadmeFile, readmeContentFallback),
configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback),
};

View file

@ -0,0 +1,56 @@
import { GetConnectorConfig } from '@logto/connector-types';
import SmtpConnector from '.';
import { SmtpConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig<SmtpConfig>;
const smtpMethods = new SmtpConnector(getConnectorConfig);
describe('validateConfig()', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should pass on valid config', async () => {
await expect(
smtpMethods.validateConfig({
host: 'smtp.testing.com',
port: 80,
password: 'password',
username: 'username',
fromEmail: 'test@smtp.testing.com',
templates: [
{
contentType: 'text/plain',
content: 'This is for testing purposes only.',
subject: 'Logto Test with SMTP',
usageType: 'Test',
},
{
contentType: 'text/plain',
content: 'This is for sign-in purposes only.',
subject: 'Logto Sign In with SMTP',
usageType: 'SignIn',
},
{
contentType: 'text/plain',
content: 'This is for register purposes only.',
subject: 'Logto Register with SMTP',
usageType: 'Register',
},
{
contentType: 'text/plain',
content: 'This is for forgot-password purposes only.',
subject: 'Logto Forgot Password with SMTP',
usageType: 'ForgotPassword',
},
],
})
).resolves.not.toThrow();
});
it('throws if config is invalid', async () => {
await expect(smtpMethods.validateConfig({})).rejects.toThrow();
});
});

View file

@ -0,0 +1,98 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
EmailSendMessageFunction,
ValidateConfig,
EmailConnector,
GetConnectorConfig,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import nodemailer from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { defaultMetadata } from './constant';
import { ContextType, smtpConfigGuard, SmtpConfig } from './types';
export default class SmtpConnector implements EmailConnector {
public metadata: ConnectorMetadata = defaultMetadata;
constructor(public readonly getConfig: GetConnectorConfig<SmtpConfig>) {}
public validateConfig: ValidateConfig = async (config: unknown) => {
const result = smtpConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message);
}
};
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await this.getConfig(this.metadata.id);
await this.validateConfig(config);
const { host, port, username, password, fromEmail, replyTo, templates } = config;
const template = templates.find((template) => template.usageType === type);
assert(
template,
new ConnectorError(
ConnectorErrorCodes.TemplateNotFound,
`Template not found for type: ${type}`
)
);
const configOptions: SMTPTransport.Options = {
host,
port,
auth: {
user: username,
pass: password,
},
// Set `secure` to be false and `requireTLS` to be true to make sure `nodemailer` calls STARTTLS, which is wildly adopted in email servers.
secure: false,
requireTLS: true,
// Enable `logger` to help debugging.
logger: true,
};
const transporter = nodemailer.createTransport(configOptions);
const contentsObject = this.parseContents(
typeof data.code === 'string'
? template.content.replace(/{{code}}/g, data.code)
: template.content,
template.contentType
);
const mailOptions = {
to: address,
from: fromEmail,
replyTo,
subject: template.subject,
...contentsObject,
};
try {
return await transporter.sendMail(mailOptions);
} catch (error: unknown) {
throw new ConnectorError(
ConnectorErrorCodes.General,
error instanceof Error ? error.message : ''
);
}
};
private readonly parseContents = (contents: string, contentType: ContextType) => {
switch (contentType) {
case ContextType.Text:
return { text: contents };
case ContextType.Html:
return { html: contents };
default:
throw new ConnectorError(
ConnectorErrorCodes.InvalidConfig,
'`contentType` should be ContextType.'
);
}
};
}

View file

@ -0,0 +1,34 @@
import { emailRegEx } from '@logto/shared';
import { z } from 'zod';
/**
* @doc https://nodemailer.com/smtp/
*/
/**
* UsageType here is used to specify the use case of the template, can be either
* 'Register', 'SignIn', 'ForgotPassword' or 'Test'.
*/
export enum ContextType {
Text = 'text/plain',
Html = 'text/html',
}
const templateGuard = z.object({
usageType: z.string(),
contentType: z.nativeEnum(ContextType),
subject: z.string(),
content: z.string(), // With variable {{code}}, support HTML
});
export const smtpConfigGuard = z.object({
host: z.string(),
port: z.number(),
username: z.string(),
password: z.string(),
fromEmail: z.string().regex(emailRegEx),
replyTo: z.string().regex(emailRegEx).optional(),
templates: z.array(templateGuard),
});
export type SmtpConfig = z.infer<typeof smtpConfigGuard>;

View file

@ -0,0 +1,10 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.base",
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base",
"compilerOptions": {
"types": ["node", "jest"]
},
"include": ["src", "jest.config.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false,
"allowJs": true,
}
}

View file

@ -28,6 +28,7 @@
"@logto/connector-github": "^0.1.0",
"@logto/connector-google": "^0.1.0",
"@logto/connector-sendgrid-email": "^0.1.0",
"@logto/connector-smtp": "^0.1.0",
"@logto/connector-twilio-sms": "^0.1.0",
"@logto/connector-types": "^0.1.0",
"@logto/connector-wechat": "^0.1.0",

View file

@ -9,6 +9,7 @@ export const connectorPackages = [
'@logto/connector-github',
'@logto/connector-google',
'@logto/connector-sendgrid-email',
'@logto/connector-smtp',
'@logto/connector-twilio-sms',
'@logto/connector-wechat',
'@logto/connector-wechat-native',

View file

@ -64,6 +64,12 @@ const sendGridMailConnector = {
config: {},
createdAt: 1_646_382_233_111,
};
const smtpConnector = {
id: 'simple-mail-transfer-protocol',
enabled: false,
config: {},
createdAt: 1_646_382_233_111,
};
const twilioSmsConnector = {
id: 'twilio-short-message-service',
enabled: false,
@ -93,6 +99,7 @@ const connectors = [
githubConnector,
googleConnector,
sendGridMailConnector,
smtpConnector,
twilioSmsConnector,
wechatConnector,
wechatNativeConnector,

File diff suppressed because it is too large Load diff