diff --git a/.changeset/dull-starfishes-scream.md b/.changeset/dull-starfishes-scream.md
new file mode 100644
index 000000000..4656ee961
--- /dev/null
+++ b/.changeset/dull-starfishes-scream.md
@@ -0,0 +1,5 @@
+---
+"@logto/connector-smsaero": minor
+---
+
+Add SMSAero connector
diff --git a/packages/connectors/connector-smsaero/README.md b/packages/connectors/connector-smsaero/README.md
new file mode 100644
index 000000000..572d4697f
--- /dev/null
+++ b/packages/connectors/connector-smsaero/README.md
@@ -0,0 +1,64 @@
+# SMSAero short message service connector
+
+The official Logto connector for SMSAero short message service.
+
+**Table of contents**
+
+- [SMSAero short message service connector](#smsaero-short-message-service-connector)
+ - [Register account](#register-account)
+ - [Set up senders' phone numbers](#set-up-senders-phone-numbers)
+ - [Get account credentials](#get-account-credentials)
+ - [Compose the connector JSON](#compose-the-connector-json)
+ - [Test SMSAero connector](#test-smsaero-connector)
+ - [Config types](#config-types)
+ - [Reference](#reference)
+
+## Register account
+
+Create a new account on [SMSAero](https://smsaero.ru/). (Jump to the next step if you already have one.)
+
+## Get account credentials
+
+We will need the API credentials to make the connector work. Let's begin from
+the [API and SMPP](https://smsaero.ru/cabinet/settings/apikey/).
+
+Copy "API-key" or generate new one.
+
+## Compose the connector JSON
+
+Fill out the _email_, _apiKey_ and _senderName_ fields with your email, API key and sender name.
+
+You can fill sender name with "SMSAero" to use default sender name provided by SMSAero.
+
+You can add multiple SMS connector templates for different cases. Here is an example of adding a single template:
+
+- Fill out the `content` field with arbitrary string-typed contents. Do not forget to leave `{{code}}` placeholder for
+ random verification code.
+- Fill out the `usageType` field with either `Register`, `SignIn`, `ForgotPassword`, `Generic` for different use cases.
+ In order to enable full user flows, templates with usageType `Register`, `SignIn`, `ForgotPassword` and `Generic` are
+ required.
+
+### Test SMSAero connector
+
+You can enter a phone number and click on "Send" to see whether the settings can work before "Save and Done".
+
+That's it. Don't forget
+to [Enable connector in sign-in experience](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-sms-or-email-passwordless-sign-in).
+
+### Config types
+
+| Name | Type |
+|------------|-------------|
+| email | string |
+| apiKey | string |
+| senderName | string |
+| templates | Templates[] |
+
+| Template Properties | Type | Enum values |
+|---------------------|-------------|---------------------------------------------------------|
+| content | string | N/A |
+| usageType | enum string | 'Register' \| 'SignIn' \| 'ForgotPassword' \| 'Generic' |
+
+## Reference
+
+- [SMSAero API Documentation](https://smsaero.ru/integration/documentation/api/)
diff --git a/packages/connectors/connector-smsaero/logo.svg b/packages/connectors/connector-smsaero/logo.svg
new file mode 100644
index 000000000..e3758e9e2
--- /dev/null
+++ b/packages/connectors/connector-smsaero/logo.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/packages/connectors/connector-smsaero/package.json b/packages/connectors/connector-smsaero/package.json
new file mode 100644
index 000000000..79b4b5fdc
--- /dev/null
+++ b/packages/connectors/connector-smsaero/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@logto/connector-smsaero",
+ "version": "1.0.0",
+ "description": "SMSAero connector implementation.",
+ "author": "Danil Tankov ",
+ "dependencies": {
+ "@logto/connector-kit": "workspace:^1.1.0"
+ },
+ "main": "./lib/index.js",
+ "module": "./lib/index.js",
+ "exports": "./lib/index.js",
+ "license": "MPL-2.0",
+ "type": "module",
+ "files": [
+ "lib",
+ "docs",
+ "logo.svg",
+ "logo-dark.svg"
+ ],
+ "scripts": {
+ "precommit": "lint-staged",
+ "build:test": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
+ "build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c",
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
+ "lint": "eslint --ext .ts src",
+ "lint:report": "pnpm lint --format json --output-file report.json",
+ "test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
+ "test": "pnpm build:test && pnpm test:only",
+ "test:ci": "pnpm test:only --silent --coverage",
+ "prepublishOnly": "pnpm build"
+ },
+ "engines": {
+ "node": "^18.12.0"
+ },
+ "eslintConfig": {
+ "extends": "@silverhand",
+ "settings": {
+ "import/core-modules": [
+ "@silverhand/essentials",
+ "got",
+ "nock",
+ "snakecase-keys",
+ "zod"
+ ]
+ }
+ },
+ "prettier": "@silverhand/eslint-config/.prettierrc",
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/connectors/connector-smsaero/src/constant.ts b/packages/connectors/connector-smsaero/src/constant.ts
new file mode 100644
index 000000000..71a6cdbb6
--- /dev/null
+++ b/packages/connectors/connector-smsaero/src/constant.ts
@@ -0,0 +1,70 @@
+import type { ConnectorMetadata } from '@logto/connector-kit';
+import { ConnectorConfigFormItemType } from '@logto/connector-kit';
+
+export const endpoint = `https://gate.smsaero.ru/v2/sms/send`;
+
+export const defaultMetadata: ConnectorMetadata = {
+ id: 'smsaero-short-message-service',
+ target: 'smsaero-sms',
+ platform: null,
+ name: {
+ en: 'SMS Aero service',
+ },
+ logo: './logo.svg',
+ logoDark: null,
+ description: {
+ en: 'SMS Aero offers users to use SMS-mailing in 5 minutes without viewing the contract. Developers are offered a convenient API with accessible classes and 24x7 chat support.',
+ },
+ readme: './README.md',
+ formItems: [
+ {
+ key: 'email',
+ label: 'Email',
+ type: ConnectorConfigFormItemType.Text,
+ required: true,
+ placeholder: '',
+ },
+ {
+ key: 'apiKey',
+ label: 'API Key',
+ type: ConnectorConfigFormItemType.Text,
+ required: true,
+ placeholder: '',
+ },
+ {
+ key: 'senderName',
+ label: 'Sender Name',
+ type: ConnectorConfigFormItemType.Text,
+ required: true,
+ placeholder: 'SMSAero',
+ },
+ {
+ key: 'templates',
+ label: 'Templates',
+ type: ConnectorConfigFormItemType.Json,
+ required: true,
+ defaultValue: [
+ {
+ usageType: 'SignIn',
+ content:
+ 'Your Logto sign-in verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ {
+ usageType: 'Register',
+ content:
+ 'Your Logto sign-up verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ {
+ usageType: 'ForgotPassword',
+ content:
+ 'Your Logto password change verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ {
+ usageType: 'Generic',
+ content:
+ 'Your Logto verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ ],
+ },
+ ],
+};
diff --git a/packages/connectors/connector-smsaero/src/index.test.ts b/packages/connectors/connector-smsaero/src/index.test.ts
new file mode 100644
index 000000000..f2a33ba12
--- /dev/null
+++ b/packages/connectors/connector-smsaero/src/index.test.ts
@@ -0,0 +1,12 @@
+import createConnector from './index.js';
+import { mockedConfig } from './mock.js';
+
+const { jest } = import.meta;
+
+const getConfig = jest.fn().mockResolvedValue(mockedConfig);
+
+describe('SMSAero SMS connector', () => {
+ it('init without throwing errors', async () => {
+ await expect(createConnector({ getConfig })).resolves.not.toThrow();
+ });
+});
diff --git a/packages/connectors/connector-smsaero/src/index.ts b/packages/connectors/connector-smsaero/src/index.ts
new file mode 100644
index 000000000..419eb62a3
--- /dev/null
+++ b/packages/connectors/connector-smsaero/src/index.ts
@@ -0,0 +1,85 @@
+import { assert } from '@silverhand/essentials';
+import { got, HTTPError } from 'got';
+
+import type {
+ CreateConnector,
+ GetConnectorConfig,
+ SendMessageFunction,
+ SmsConnector,
+} from '@logto/connector-kit';
+import {
+ ConnectorError,
+ ConnectorErrorCodes,
+ ConnectorType,
+ validateConfig,
+} from '@logto/connector-kit';
+
+import { defaultMetadata, endpoint } from './constant.js';
+import type { PublicParameters, SmsAeroConfig } from './types.js';
+import { smsAeroConfigGuard } from './types.js';
+
+function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction {
+ return async (data, inputConfig) => {
+ const { to, type, payload } = data;
+
+ const config = inputConfig ?? (await getConfig(defaultMetadata.id));
+ validateConfig(config, smsAeroConfigGuard);
+
+ const { email, apiKey, senderName, templates } = config;
+
+ const template = templates.find((template) => template.usageType === type);
+
+ assert(
+ template,
+ new ConnectorError(
+ ConnectorErrorCodes.TemplateNotFound,
+ `Cannot find template for type: ${type}`
+ )
+ );
+
+ const parameters: PublicParameters = {
+ number: to,
+ sign: senderName,
+ text: template.content.replace(/{{code}}/g, payload.code),
+ };
+
+ const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
+
+ try {
+ return await got.post(endpoint, {
+ headers: {
+ Authorization: `Basic ${auth}`,
+ },
+ json: parameters,
+ });
+ } catch (error: unknown) {
+ if (error instanceof HTTPError) {
+ const {
+ response: { body: rawBody },
+ } = error;
+ assert(
+ typeof rawBody === 'string',
+ new ConnectorError(
+ ConnectorErrorCodes.InvalidResponse,
+ `Invalid response raw body type: ${typeof rawBody}`
+ )
+ );
+
+ throw new ConnectorError(ConnectorErrorCodes.General, rawBody);
+ }
+
+ throw error;
+ }
+ };
+}
+
+const createSmsAeroConnector: CreateConnector = async ({ getConfig }) => {
+ return {
+ metadata: defaultMetadata,
+ type: ConnectorType.Sms,
+ configGuard: smsAeroConfigGuard,
+ sendMessage: sendMessage(getConfig),
+ };
+};
+
+export default createSmsAeroConnector;
diff --git a/packages/connectors/connector-smsaero/src/mock.ts b/packages/connectors/connector-smsaero/src/mock.ts
new file mode 100644
index 000000000..055f32ace
--- /dev/null
+++ b/packages/connectors/connector-smsaero/src/mock.ts
@@ -0,0 +1,17 @@
+import type { SmsAeroConfig } from './types.js';
+
+const mockedEmail = 'test@test.com';
+const mockedApiKey = 'api-key';
+const mockedSenderName = 'sender-name';
+
+export const mockedConfig: SmsAeroConfig = {
+ email: mockedEmail,
+ apiKey: mockedApiKey,
+ senderName: mockedSenderName,
+ templates: [
+ {
+ usageType: 'Test',
+ content: 'This is for testing purposes only. Your verification code is {{code}}.',
+ },
+ ],
+};
diff --git a/packages/connectors/connector-smsaero/src/types.ts b/packages/connectors/connector-smsaero/src/types.ts
new file mode 100644
index 000000000..dfdb5aea2
--- /dev/null
+++ b/packages/connectors/connector-smsaero/src/types.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod';
+
+/**
+ * @doc https://smsaero.ru/integration/documentation/api/
+ */
+
+export type PublicParameters = {
+ number: string;
+ sign: string;
+ text: string;
+};
+
+/**
+ * UsageType here is used to specify the use case of the template, can be either
+ * 'Register', 'SignIn', 'ForgotPassword', 'Generic' or 'Test'.
+ */
+const requiredTemplateUsageTypes = ['Register', 'SignIn', 'ForgotPassword', 'Generic'];
+
+const templateGuard = z.object({
+ usageType: z.string(),
+ content: z.string(),
+});
+
+export const smsAeroConfigGuard = z.object({
+ email: z.string().email(),
+ apiKey: z.string(),
+ senderName: z.string(),
+ templates: z.array(templateGuard).refine(
+ (templates) =>
+ requiredTemplateUsageTypes.every((requiredType) =>
+ templates.map((template) => template.usageType).includes(requiredType)
+ ),
+ (templates) => ({
+ message: `Template with UsageType (${requiredTemplateUsageTypes
+ .filter(
+ (requiredType) => !templates.map((template) => template.usageType).includes(requiredType)
+ )
+ .join(', ')}) should be provided!`,
+ })
+ ),
+});
+
+export type SmsAeroConfig = z.infer;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4737118a3..d910f98ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2263,6 +2263,85 @@ importers:
specifier: ^5.0.0
version: 5.0.2
+ packages/connectors/connector-smsaero:
+ dependencies:
+ '@logto/connector-kit':
+ specifier: workspace:^1.1.0
+ version: link:../../toolkit/connector-kit
+ '@silverhand/essentials':
+ specifier: ^2.5.0
+ version: 2.5.0
+ got:
+ specifier: ^13.0.0
+ version: 13.0.0
+ snakecase-keys:
+ specifier: ^5.4.4
+ version: 5.4.4
+ zod:
+ specifier: ^3.20.2
+ version: 3.20.2
+ devDependencies:
+ '@jest/types':
+ specifier: ^29.5.0
+ version: 29.5.0
+ '@rollup/plugin-commonjs':
+ specifier: ^25.0.0
+ version: 25.0.0(rollup@3.8.0)
+ '@rollup/plugin-json':
+ specifier: ^6.0.0
+ version: 6.0.0(rollup@3.8.0)
+ '@rollup/plugin-node-resolve':
+ specifier: ^15.0.1
+ version: 15.0.1(rollup@3.8.0)
+ '@rollup/plugin-typescript':
+ specifier: ^11.0.0
+ version: 11.0.0(rollup@3.8.0)(typescript@5.0.2)
+ '@silverhand/eslint-config':
+ specifier: 3.0.1
+ version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2)
+ '@silverhand/ts-config':
+ specifier: 3.0.0
+ version: 3.0.0(typescript@5.0.2)
+ '@types/jest':
+ specifier: ^29.4.0
+ version: 29.4.0
+ '@types/node':
+ specifier: ^18.11.18
+ version: 18.11.18
+ '@types/supertest':
+ specifier: ^2.0.11
+ version: 2.0.11
+ eslint:
+ specifier: ^8.34.0
+ version: 8.34.0
+ jest:
+ specifier: ^29.5.0
+ version: 29.5.0(@types/node@18.11.18)
+ jest-matcher-specific-error:
+ specifier: ^1.0.0
+ version: 1.0.0
+ lint-staged:
+ specifier: ^13.0.0
+ version: 13.0.0
+ nock:
+ specifier: ^13.2.2
+ version: 13.2.2
+ prettier:
+ specifier: ^2.8.2
+ version: 2.8.4
+ rollup:
+ specifier: ^3.8.0
+ version: 3.8.0
+ rollup-plugin-summary:
+ specifier: ^2.0.0
+ version: 2.0.0(rollup@3.8.0)
+ supertest:
+ specifier: ^6.2.2
+ version: 6.2.2
+ typescript:
+ specifier: ^5.0.0
+ version: 5.0.2
+
packages/connectors/connector-smtp:
dependencies:
'@logto/connector-kit':
@@ -10745,7 +10824,7 @@ packages:
dev: false
/concat-map@0.0.1:
- resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
/concat-stream@2.0.0:
@@ -11328,7 +11407,7 @@ packages:
dev: true
/dezalgo@1.0.3:
- resolution: {integrity: sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=}
+ resolution: {integrity: sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==}
dependencies:
asap: 2.0.6
wrappy: 1.0.2