diff --git a/.changeset/brown-donkeys-share.md b/.changeset/brown-donkeys-share.md
new file mode 100644
index 000000000..8bcbc2198
--- /dev/null
+++ b/.changeset/brown-donkeys-share.md
@@ -0,0 +1,5 @@
+---
+"@logto/connector-yunpian-sms": minor
+---
+
+add YunPian SMS connector
diff --git a/packages/connectors/connector-xiaomi/package.json b/packages/connectors/connector-xiaomi/package.json
index dfec997d9..d121fed31 100644
--- a/packages/connectors/connector-xiaomi/package.json
+++ b/packages/connectors/connector-xiaomi/package.json
@@ -26,7 +26,6 @@
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint --ext .ts src",
- "lint:fix": "eslint --ext .ts src --fix",
"lint:report": "pnpm lint --format json --output-file report.json",
"test": "vitest src",
"test:ci": "pnpm run test --silent --coverage",
diff --git a/packages/connectors/connector-yunpian-sms/README.md b/packages/connectors/connector-yunpian-sms/README.md
new file mode 100644
index 000000000..415bcb5e1
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/README.md
@@ -0,0 +1,60 @@
+# Yunpian SMS connector
+
+The official Logto connector for Yunpian SMS service. [中文文档](https://github.com/logto-io/logto/tree/master/packages/connectors/connector-yunpian-sms/README.zh-CN.md)
+
+**Table of contents**
+
+- [Yunpian SMS connector](#yunpian-sms-connector)
+ - [Get started](#get-started)
+ - [Set up SMS service in Yunpian Console](#set-up-sms-service-in-yunpian-console)
+ - [Create a Yunpian account](#create-a-yunpian-account)
+ - [Get API KEY](#get-api-key)
+ - [Configure SMS templates](#configure-sms-templates)
+ - [Configure in Logto](#configure-in-logto)
+ - [Notes](#notes)
+ - [References](#references)
+
+## Get started
+
+Yunpian is a communication service provider offering various services including SMS. The Yunpian SMS Connector is a plugin provided by the Logto team to integrate with Yunpian's SMS service, enabling Logto end-users to register and sign in via SMS verification codes.
+
+## Set up SMS service in Yunpian Console
+
+### Create a Yunpian account
+
+Visit [Yunpian's website](https://www.yunpian.com/) to register an account and complete real-name verification.
+
+### Get API KEY
+
+1. Log in to Yunpian Console
+2. Go to "Account Settings" -> "Sub-account Management"
+3. Find and copy the API KEY
+
+### Configure SMS templates
+
+1. In Yunpian Console, go to "Domestic SMS" -> "Signature Filing"
+2. Create and submit a signature, wait for carrier approval
+3. Go to "Domestic SMS" -> "Template Filing" and select "Verification Code"
+4. Create a verification code template, ensure it includes the `#code#` variable (you can also use "Common Templates" to speed up the approval process)
+5. Wait for template approval
+6. If you need to send international SMS, repeat the above steps but select "International SMS" -> "Template Filing"
+
+## Configure in Logto
+
+1. In Logto Console, go to "Connectors"
+2. Find and click "Yunpian SMS Service"
+3. Fill in the configuration form:
+ - API KEY: The API KEY obtained from Yunpian
+ - SMS templates: Configure templates according to usage, ensure they match exactly with approved templates in Yunpian
+
+## Notes
+
+1. SMS template content must match exactly with the approved template in Yunpian
+2. The verification code variable placeholder in Yunpian templates is `#code#`, while in the connector configuration it's `{{code}}`
+3. Yunpian automatically adds the default signature based on API KEY, no need to include signature in the template content
+4. It's recommended to test the configuration before formal use
+
+## References
+
+- [Yunpian Development Documentation](https://www.yunpian.com/official/document/sms/zh_CN/introduction_brief)
+- [Logto SMS Connector Guide](https://docs.logto.io/docs/recipes/configure-connectors/sms-connector/)
diff --git a/packages/connectors/connector-yunpian-sms/README.zh-CN.md b/packages/connectors/connector-yunpian-sms/README.zh-CN.md
new file mode 100644
index 000000000..25d9984e5
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/README.zh-CN.md
@@ -0,0 +1,60 @@
+# 云片网短信连接器
+
+云片网短信服务 Logto 官方连接器
+
+**目录**
+
+- [云片网短信连接器](#云片网短信连接器)
+ - [开始使用](#开始使用)
+ - [在云片网中配置](#在云片网中配置)
+ - [创建云片网账号](#创建云片网账号)
+ - [获取 API KEY](#获取-api-key)
+ - [配置短信模板](#配置短信模板)
+ - [在 Logto 中配置](#在-logto-中配置)
+ - [注意事项](#注意事项)
+ - [参考](#参考)
+
+## 开始使用
+
+云片网是一家通信服务提供商,提供包括短信在内的多种通信服务。云片网 SMS 连接器是由 Logto 团队提供的插件,用于调用云片网的短信服务,帮助 Logto 终端用户通过短信验证码进行注册和登录。
+
+## 在云片网中配置
+
+### 创建云片网账号
+
+访问[云片网官网](https://www.yunpian.com/),注册账号并完成实名认证。
+
+### 获取 API KEY
+
+1. 登录云片网控制台
+2. 进入"账户设置" -> "子账户管理"
+3. 找到并复制 API KEY
+
+### 配置短信模板
+
+1. 在云片网控制台中进入"国内短信" -> "签名报备"
+2. 创建签名并提交,等待运营商审核通过
+3. 在云片网控制台中进入"国内短信" -> "模板报备",选择"验证码类"
+4. 创建验证码类短信模板,确保模板中包含 `#code#` 变量(也可以直接使用`常用模板`申请,加快审核速度)
+5. 等待模板审核通过
+6. 如果您需要发送国际短信,请重复上述步骤,选择"国际短信" -> "模板报备"并提交
+
+## 在 Logto 中配置
+
+1. 在 Logto 管理控制台中转到"连接器"
+2. 找到并点击"云片短信服务"
+3. 在配置表单中填入:
+ - API KEY: 从云片网获取的 API KEY
+ - 短信模板: 按照用途配置相应的模板内容,确保与云片网审核通过的模板内容一致
+
+## 注意事项
+
+1. 短信模板内容必须与云片网审核通过的模板完全一致
+2. 短信审核模板中的验证码变量占位符为 `#code#`,模板配置中验证码变量占位符为 `{{code}}`
+3. 云片网会自动根据 API KEY 添加默认签名,无需在发送模板内容中包含签名
+4. 建议在正式使用前进行测试,确保配置正确
+
+## 参考
+
+- [云片网开发文档](https://www.yunpian.com/official/document/sms/zh_CN/introduction_brief)
+- [Logto SMS 连接器指南](https://docs.logto.io/zh-CN/connectors/sms-connectors)
diff --git a/packages/connectors/connector-yunpian-sms/logo.svg b/packages/connectors/connector-yunpian-sms/logo.svg
new file mode 100644
index 000000000..998c7376a
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/logo.svg
@@ -0,0 +1,13 @@
+
+
diff --git a/packages/connectors/connector-yunpian-sms/package.json b/packages/connectors/connector-yunpian-sms/package.json
new file mode 100644
index 000000000..2c91d372b
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/package.json
@@ -0,0 +1,68 @@
+{
+ "name": "@logto/connector-yunpian-sms",
+ "version": "1.0.0",
+ "description": "云片网 SMS connector implementation.",
+ "author": "Silverhand Inc. ",
+ "dependencies": {
+ "@logto/connector-kit": "workspace:^4.0.0",
+ "@silverhand/essentials": "^2.9.1",
+ "got": "^14.0.0",
+ "zod": "^3.23.8"
+ },
+ "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",
+ "check": "tsc --noEmit",
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "lint": "eslint --ext .ts src",
+ "lint:report": "pnpm lint --format json --output-file report.json",
+ "test": "vitest src",
+ "test:ci": "pnpm run test --silent --coverage",
+ "prepublishOnly": "pnpm build"
+ },
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "eslintConfig": {
+ "extends": "@silverhand",
+ "settings": {
+ "import/core-modules": [
+ "@silverhand/essentials",
+ "got",
+ "nock",
+ "snakecase-keys",
+ "zod"
+ ]
+ }
+ },
+ "prettier": "@silverhand/eslint-config/.prettierrc",
+ "publishConfig": {
+ "access": "public"
+ },
+ "devDependencies": {
+ "@silverhand/eslint-config": "6.0.1",
+ "@silverhand/ts-config": "6.0.0",
+ "@types/node": "^20.11.20",
+ "@types/supertest": "^6.0.2",
+ "@vitest/coverage-v8": "^2.1.8",
+ "eslint": "^8.56.0",
+ "lint-staged": "^15.0.2",
+ "nock": "^13.3.1",
+ "prettier": "^3.0.0",
+ "supertest": "^7.0.0",
+ "tsup": "^8.3.0",
+ "typescript": "^5.5.3",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/packages/connectors/connector-yunpian-sms/src/constant.ts b/packages/connectors/connector-yunpian-sms/src/constant.ts
new file mode 100644
index 000000000..613a7f5b0
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/src/constant.ts
@@ -0,0 +1,72 @@
+import type { ConnectorMetadata } from '@logto/connector-kit';
+import { ConnectorConfigFormItemType } from '@logto/connector-kit';
+
+export const endpoint = 'https://sms.yunpian.com/v2/sms/single_send.json';
+
+export const defaultMetadata: ConnectorMetadata = {
+ id: 'yunpian-sms',
+ target: 'yunpian-sms',
+ platform: null,
+ name: {
+ en: 'YunPian SMS Service',
+ zh: '云片短信服务',
+ },
+ logo: './logo.svg',
+ logoDark: null,
+ description: {
+ en: 'YunPian is a SMS service provider.',
+ zh: '云片网是一家短信服务提供商。',
+ },
+ readme: './README.md',
+ formItems: [
+ {
+ key: 'apikey',
+ label: 'API Key',
+ type: ConnectorConfigFormItemType.Text,
+ required: true,
+ placeholder: '',
+ },
+ {
+ key: 'templates',
+ label: 'SMS Template',
+ type: ConnectorConfigFormItemType.Json,
+ required: true,
+ defaultValue: [
+ {
+ usageType: 'SignIn',
+ content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信',
+ },
+ {
+ usageType: 'Register',
+ content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信',
+ },
+ {
+ usageType: 'ForgotPassword',
+ content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信',
+ },
+ {
+ usageType: 'Generic',
+ content: '您的验证码是 {{code}}。如非本人操作,请忽略本短信',
+ },
+ ],
+ },
+ {
+ key: 'enableInternational',
+ label: 'Enable International SMS',
+ description:
+ '* To enable it, you need to apply for international templates at the same time.',
+ type: ConnectorConfigFormItemType.Switch,
+ required: false,
+ defaultValue: false,
+ },
+ {
+ key: 'unsupportedCountriesMsg',
+ label: 'Unsupported Countries Error Message',
+ description:
+ 'The message to be displayed when the phone number is not supported. If left empty, no error will be returned.',
+ type: ConnectorConfigFormItemType.Text,
+ required: false,
+ defaultValue: 'The administrator has not enabled international SMS services.',
+ },
+ ],
+};
diff --git a/packages/connectors/connector-yunpian-sms/src/index.test.ts b/packages/connectors/connector-yunpian-sms/src/index.test.ts
new file mode 100644
index 000000000..1d4942330
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/src/index.test.ts
@@ -0,0 +1,54 @@
+import nock from 'nock';
+
+import { TemplateType } from '@logto/connector-kit';
+
+import { endpoint } from './constant.js';
+import createConnector from './index.js';
+import { mockedConfig } from './mock.js';
+
+const getConfig = vi.fn().mockResolvedValue(mockedConfig);
+
+describe('yunpian SMS connector', () => {
+ it('init without throwing errors', async () => {
+ await expect(createConnector({ getConfig })).resolves.not.toThrow();
+ });
+
+ describe('sendMessage()', async () => {
+ const connector = await createConnector({ getConfig });
+ const { sendMessage } = connector;
+
+ beforeAll(() => {
+ nock.disableNetConnect();
+ });
+
+ afterAll(() => {
+ nock.enableNetConnect();
+ });
+
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ it('should send message successfully', async () => {
+ const mockResponse = {
+ code: 0,
+ msg: '发送成功',
+ count: 1,
+ fee: 0.05,
+ unit: 'RMB',
+ mobile: '13800138000',
+ sid: 3_310_228_982,
+ };
+
+ nock(endpoint).post('').reply(200, mockResponse);
+
+ await expect(
+ sendMessage({
+ to: '13800138000',
+ type: TemplateType.Generic,
+ payload: { code: '1234' },
+ })
+ ).resolves.not.toThrow();
+ });
+ });
+});
diff --git a/packages/connectors/connector-yunpian-sms/src/index.ts b/packages/connectors/connector-yunpian-sms/src/index.ts
new file mode 100644
index 000000000..ff53bd705
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/src/index.ts
@@ -0,0 +1,127 @@
+import { assert } from '@silverhand/essentials';
+import { got, RequestError } from 'got';
+
+import type {
+ GetConnectorConfig,
+ SendMessageFunction,
+ CreateConnector,
+ SmsConnector,
+} from '@logto/connector-kit';
+import {
+ ConnectorError,
+ ConnectorErrorCodes,
+ validateConfig,
+ ConnectorType,
+ replaceSendMessageHandlebars,
+} from '@logto/connector-kit';
+
+import { defaultMetadata, endpoint } from './constant.js';
+import {
+ yunpianSmsConfigGuard,
+ type YunpianSmsPayload,
+ yunpianErrorResponseGuard,
+} from './types.js';
+
+const isChinaPhoneNumber = (phone: string) => {
+ // Match formats:
+ // 1. +86 followed by 11 digits, first digit must be 1
+ // 2. 86 followed by 11 digits, first digit must be 1
+ const pattern = /^(\+?86)1[3-9]\d{9}$/;
+
+ return pattern.test(phone);
+};
+
+const formatPhoneNumber = (phoneNumber: string) => {
+ const phone = phoneNumber.replaceAll(/\s/g, '');
+
+ if (!isChinaPhoneNumber(phone)) {
+ if (!phone.startsWith('+')) {
+ return `+${phone}`;
+ }
+ return phone;
+ }
+
+ // If it starts with +86 or 86, truncate the last 11 digits
+ if (phone.startsWith('+86')) {
+ return phone.slice(3);
+ }
+ if (phone.startsWith('86')) {
+ return phone.slice(2);
+ }
+
+ return phone;
+};
+
+const sendMessage =
+ (getConfig: GetConnectorConfig): SendMessageFunction =>
+ async (data, inputConfig) => {
+ const { to, type, payload } = data;
+ const config = inputConfig ?? (await getConfig(defaultMetadata.id));
+ validateConfig(config, yunpianSmsConfigGuard);
+ const { apikey, templates, enableInternational, unsupportedCountriesMsg } = config;
+
+ const template = templates.find((template) => template.usageType === type);
+ assert(
+ template,
+ new ConnectorError(
+ ConnectorErrorCodes.TemplateNotFound,
+ `No SMS template found for type ${type}`
+ )
+ );
+
+ const messageContent = replaceSendMessageHandlebars(template.content, payload);
+
+ const formattedPhone = formatPhoneNumber(to);
+
+ if (!enableInternational && formattedPhone.startsWith('+')) {
+ if (unsupportedCountriesMsg) {
+ throw new ConnectorError(ConnectorErrorCodes.General, unsupportedCountriesMsg);
+ } else {
+ console.warn(`connector-yunpian-sms: unsupported phone number: ${formattedPhone}`);
+ return;
+ }
+ }
+
+ const body: YunpianSmsPayload = {
+ apikey,
+ mobile: formattedPhone,
+ text: messageContent,
+ };
+
+ try {
+ return await got.post(endpoint, {
+ form: body,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
+ Accept: 'application/json;charset=utf-8',
+ },
+ });
+ } catch (error: unknown) {
+ if (
+ error instanceof RequestError &&
+ error.response?.statusCode === 400 &&
+ typeof error.response.body === 'string'
+ ) {
+ const errorBody = yunpianErrorResponseGuard.parse(JSON.parse(error.response.body));
+ console.warn('connector-yunpian-sms: send error', errorBody);
+
+ if (errorBody.msg) {
+ throw new ConnectorError(ConnectorErrorCodes.General, errorBody.msg);
+ }
+ }
+
+ console.warn('connector-yunpian-sms: send unknown error', error);
+ throw new ConnectorError(ConnectorErrorCodes.General, `Unknown error: ${String(error)}`);
+ }
+ };
+
+const createYunpianSmsConnector: CreateConnector = async ({ getConfig }) => {
+ return {
+ metadata: defaultMetadata,
+ type: ConnectorType.Sms,
+ configGuard: yunpianSmsConfigGuard,
+ sendMessage: sendMessage(getConfig),
+ };
+};
+
+export default createYunpianSmsConnector;
diff --git a/packages/connectors/connector-yunpian-sms/src/mock.ts b/packages/connectors/connector-yunpian-sms/src/mock.ts
new file mode 100644
index 000000000..2a3f0bd14
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/src/mock.ts
@@ -0,0 +1,23 @@
+import type { YunpianSmsConfig } from './types.js';
+
+export const mockedConfig: YunpianSmsConfig = {
+ apikey: 'a123b456c789d0',
+ templates: [
+ {
+ usageType: 'Generic',
+ content: '您的验证码是{{code}}。如非本人操作,请忽略本短信',
+ },
+ {
+ usageType: 'SignIn',
+ content: '您的验证码是{{code}}。如非本人操作,请忽略本短信',
+ },
+ {
+ usageType: 'Register',
+ content: '您的验证码是{{code}}。如非本人操作,请忽略本短信',
+ },
+ {
+ usageType: 'ForgotPassword',
+ content: '您的验证码是{{code}}。如非本人操作,请忽略本短信',
+ },
+ ],
+};
diff --git a/packages/connectors/connector-yunpian-sms/src/types.ts b/packages/connectors/connector-yunpian-sms/src/types.ts
new file mode 100644
index 000000000..934d528b0
--- /dev/null
+++ b/packages/connectors/connector-yunpian-sms/src/types.ts
@@ -0,0 +1,66 @@
+import { z } from 'zod';
+
+const templateGuard = z.object({
+ usageType: z.string(),
+ content: z.string(),
+});
+
+export const yunpianSmsConfigGuard = z.object({
+ apikey: z.string(),
+ templates: z
+ .array(templateGuard)
+ .refine(
+ (templates) =>
+ ['Register', 'SignIn', 'ForgotPassword', 'Generic'].every((type) =>
+ templates.map((template) => template.usageType).includes(type)
+ ),
+ {
+ message:
+ 'Must provide all required template types (Register/SignIn/ForgotPassword/Generic)',
+ }
+ ),
+ enableInternational: z.boolean().optional(),
+ unsupportedCountriesMsg: z.string().optional(),
+});
+
+export type YunpianSmsConfig = z.infer;
+
+export type YunpianSmsPayload = {
+ apikey: string;
+ mobile: string;
+ text: string;
+};
+
+export type YunpianSmsResponse = {
+ code: number;
+ msg: string;
+ count: number;
+ fee: number;
+ unit: string;
+ mobile: string;
+ sid: number;
+};
+
+export const yunpianSmsResponseGuard = z.object({
+ code: z.number(),
+ msg: z.string(),
+ count: z.number(),
+ fee: z.number(),
+ unit: z.string(),
+ mobile: z.string(),
+ sid: z.number(),
+});
+
+export type YunpianErrorResponse = {
+ http_status_code: number;
+ code: number;
+ msg: string;
+ detail?: string;
+};
+
+export const yunpianErrorResponseGuard = z.object({
+ http_status_code: z.number(),
+ code: z.number(),
+ msg: z.string(),
+ detail: z.string().optional(),
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ca630024b..c334a7703 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2794,6 +2794,61 @@ importers:
specifier: ^2.1.8
version: 2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
+ packages/connectors/connector-yunpian-sms:
+ dependencies:
+ '@logto/connector-kit':
+ specifier: workspace:^4.0.0
+ version: link:../../toolkit/connector-kit
+ '@silverhand/essentials':
+ specifier: ^2.9.1
+ version: 2.9.2
+ got:
+ specifier: ^14.0.0
+ version: 14.0.0
+ zod:
+ specifier: ^3.23.8
+ version: 3.23.8
+ devDependencies:
+ '@silverhand/eslint-config':
+ specifier: 6.0.1
+ version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3)
+ '@silverhand/ts-config':
+ specifier: 6.0.0
+ version: 6.0.0(typescript@5.5.3)
+ '@types/node':
+ specifier: ^20.11.20
+ version: 20.12.7
+ '@types/supertest':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@vitest/coverage-v8':
+ specifier: ^2.1.8
+ version: 2.1.8(vitest@2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8))
+ eslint:
+ specifier: ^8.56.0
+ version: 8.57.0
+ lint-staged:
+ specifier: ^15.0.2
+ version: 15.0.2
+ nock:
+ specifier: ^13.3.1
+ version: 13.3.1
+ prettier:
+ specifier: ^3.0.0
+ version: 3.0.0
+ supertest:
+ specifier: ^7.0.0
+ version: 7.0.0
+ tsup:
+ specifier: ^8.3.0
+ version: 8.3.0(@swc/core@1.3.52(@swc/helpers@0.5.1))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.5.3)(yaml@2.4.5)
+ typescript:
+ specifier: ^5.5.3
+ version: 5.5.3
+ vitest:
+ specifier: ^2.1.8
+ version: 2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
+
packages/console:
devDependencies:
'@fontsource/roboto-mono':