From 570a4ea9e200f38b7bdcc09be683c079e31946f4 Mon Sep 17 00:00:00 2001
From: Gao Sun <gao@silverhand.io>
Date: Thu, 25 Jan 2024 19:44:20 +0800
Subject: [PATCH] feat: create invitation (#5245)

* feat: create invitation

* refactor: update test imports

* refactor: update unit tests

* refactor: update docs

* refactor: update api tests

* chore: add changesets

* refactor: add comments

* refactor: fix swagger check

* refactor: keep compatibility
---
 .changeset/good-stingrays-perform.md          |  12 ++
 .changeset/strange-seals-poke.md              |   7 ++
 .changeset/thin-bags-admire.md                |   5 +
 .changeset/tidy-phones-warn.md                |  10 ++
 .changeset/twelve-carrots-do.md               |   5 +
 .../connector-aliyun-dm/src/index.test.ts     |  25 +++-
 .../connector-aliyun-dm/src/index.ts          |   8 +-
 .../connector-aliyun-dm/src/mock.ts           |  15 ++-
 .../connector-aliyun-sms/src/index.test.ts    |   8 +-
 .../connector-aws-ses/src/index.test.ts       |  42 ++++++-
 .../connectors/connector-aws-ses/src/mock.ts  |  13 +-
 .../connectors/connector-aws-ses/src/types.ts |   4 -
 .../connectors/connector-aws-ses/src/utils.ts |  12 +-
 .../connector-logto-email/src/index.test.ts   |   4 +-
 .../connector-logto-email/src/index.ts        |   1 +
 .../connector-logto-sms/src/index.test.ts     |   4 +-
 .../connector-mailgun/src/index.test.ts       |  53 ++++++--
 .../connectors/connector-mailgun/src/index.ts |  28 ++---
 .../connector-mailgun/src/type.test.ts        |  16 +--
 .../connectors/connector-mailgun/src/types.ts |  17 +--
 .../src/index.test.ts                         |   2 +
 .../connector-sendgrid-email/src/index.ts     |   9 +-
 .../connector-smsaero/src/index.test.ts       |   2 +
 .../connectors/connector-smsaero/src/index.ts |   3 +-
 .../connector-smtp/src/index.test.ts          |  24 +++-
 .../connectors/connector-smtp/src/index.ts    |   7 +-
 .../connectors/connector-smtp/src/mock.ts     |   6 +
 .../connector-tencent-sms/src/index.test.ts   |   4 +-
 .../connector-tencent-sms/src/index.ts        |   2 +-
 .../connector-twilio-sms/src/index.ts         |   6 +-
 packages/core/src/__mocks__/index.ts          |   4 +-
 packages/core/src/libraries/connector.ts      |  40 +++++-
 .../src/libraries/organization-invitation.ts  |  90 +++++++++++++
 packages/core/src/libraries/passcode.test.ts  | 107 ++++------------
 packages/core/src/libraries/passcode.ts       |  34 ++---
 packages/core/src/queries/magic-link.ts       |  19 +++
 .../core/src/queries/organization/index.ts    |   6 +
 packages/core/src/queries/passcode.test.ts    |  10 +-
 packages/core/src/queries/passcode.ts         |  10 +-
 .../core/src/routes-me/verification-code.ts   |   4 +-
 .../routes/connector/config-testing.test.ts   |   6 +-
 .../src/routes/connector/config-testing.ts    |   4 +-
 .../verification-code-validation.test.ts      |  14 +--
 .../utils/verification-code-validation.ts     |  20 +--
 .../organization/invitations.openapi.json     |  50 ++++++++
 .../src/routes/organization/invitations.ts    |  41 ++++++
 .../core/src/routes/verification-code.test.ts |   4 +-
 packages/core/src/routes/verification-code.ts |   4 +-
 packages/core/src/tenants/Libraries.ts        |   7 ++
 .../post-send-verification-code.test.ts       |   8 +-
 .../src/tests/api/swagger-check.test.ts       |  15 +--
 .../src/tests/api/verification-code.test.ts   |  10 +-
 .../toolkit/connector-kit/src/index.test.ts   | 119 +++++++++++-------
 packages/toolkit/connector-kit/src/index.ts   |  39 +++++-
 .../toolkit/connector-kit/src/types/error.ts  |   6 +-
 .../connector-kit/src/types/passwordless.ts   |  45 +++++--
 56 files changed, 733 insertions(+), 337 deletions(-)
 create mode 100644 .changeset/good-stingrays-perform.md
 create mode 100644 .changeset/strange-seals-poke.md
 create mode 100644 .changeset/thin-bags-admire.md
 create mode 100644 .changeset/tidy-phones-warn.md
 create mode 100644 .changeset/twelve-carrots-do.md
 create mode 100644 packages/core/src/libraries/organization-invitation.ts
 create mode 100644 packages/core/src/queries/magic-link.ts

diff --git a/.changeset/good-stingrays-perform.md b/.changeset/good-stingrays-perform.md
new file mode 100644
index 000000000..36cdf5664
--- /dev/null
+++ b/.changeset/good-stingrays-perform.md
@@ -0,0 +1,12 @@
+---
+"@logto/connector-logto-email": patch
+"@logto/connector-tencent-sms": patch
+"@logto/connector-aliyun-sms": patch
+"@logto/connector-aliyun-dm": patch
+"@logto/connector-logto-sms": patch
+"@logto/connector-aws-ses": patch
+"@logto/connector-mailgun": patch
+"@logto/connector-smtp": patch
+---
+
+support `TemplateType`
diff --git a/.changeset/strange-seals-poke.md b/.changeset/strange-seals-poke.md
new file mode 100644
index 000000000..988e9871b
--- /dev/null
+++ b/.changeset/strange-seals-poke.md
@@ -0,0 +1,7 @@
+---
+"@logto/connector-sendgrid-email": minor
+"@logto/connector-aliyun-dm": minor
+"@logto/connector-aws-ses": minor
+---
+
+support subject handlebars
diff --git a/.changeset/thin-bags-admire.md b/.changeset/thin-bags-admire.md
new file mode 100644
index 000000000..2e9946358
--- /dev/null
+++ b/.changeset/thin-bags-admire.md
@@ -0,0 +1,5 @@
+---
+"@logto/connector-kit": minor
+---
+
+add `replaceSendMessageHandlebars()` for replacing `SendMessagePayload` handlebars in a message template
diff --git a/.changeset/tidy-phones-warn.md b/.changeset/tidy-phones-warn.md
new file mode 100644
index 000000000..0120e1567
--- /dev/null
+++ b/.changeset/tidy-phones-warn.md
@@ -0,0 +1,10 @@
+---
+"@logto/connector-kit": minor
+---
+
+support magic link feature
+
+- Removed `VerificationCodeType`: Since we are adding the magic link feature, `VerificationCodeType` is no longer precise for our use cases.
+- Replaced `VerificationCodeType` with `TemplateType`.
+- Removed `TemplateNotSupported` error code since it is useless for dynamic template checking.
+- Added `link` property to `SendMessagePayload`.
diff --git a/.changeset/twelve-carrots-do.md b/.changeset/twelve-carrots-do.md
new file mode 100644
index 000000000..fe796fdca
--- /dev/null
+++ b/.changeset/twelve-carrots-do.md
@@ -0,0 +1,5 @@
+---
+"@logto/connector-mailgun": patch
+---
+
+remove `supportTemplateGuard`, support dynamic templates
diff --git a/packages/connectors/connector-aliyun-dm/src/index.test.ts b/packages/connectors/connector-aliyun-dm/src/index.test.ts
index a283de41b..9c8d0ea1a 100644
--- a/packages/connectors/connector-aliyun-dm/src/index.test.ts
+++ b/packages/connectors/connector-aliyun-dm/src/index.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import { mockedConfigWithAllRequiredTemplates } from './mock.js';
 
@@ -22,16 +22,33 @@ describe('sendMessage()', () => {
     jest.clearAllMocks();
   });
 
-  it('should call singleSendMail() and replace code in content', async () => {
+  it('should call singleSendMail() with correct template and content', async () => {
     const connector = await createConnector({ getConfig });
     await connector.sendMessage({
       to: 'to@email.com',
-      type: VerificationCodeType.SignIn,
+      type: TemplateType.SignIn,
       payload: { code: '1234' },
     });
     expect(singleSendMail).toHaveBeenCalledWith(
       expect.objectContaining({
-        HtmlBody: 'Your code is 1234, 1234 is your code',
+        HtmlBody: 'Your sign-in code is 1234, 1234 is your code',
+        Subject: 'Sign-in code 1234',
+      }),
+      expect.anything()
+    );
+  });
+
+  it('should call singleSendMail() with correct template and content (2)', async () => {
+    const connector = await createConnector({ getConfig });
+    await connector.sendMessage({
+      to: 'to@email.com',
+      type: TemplateType.OrganizationInvitation,
+      payload: { code: '1234', link: 'https://example.com' },
+    });
+    expect(singleSendMail).toHaveBeenCalledWith(
+      expect.objectContaining({
+        HtmlBody: 'Your link is https://example.com',
+        Subject: 'Organization invitation',
       }),
       expect.anything()
     );
diff --git a/packages/connectors/connector-aliyun-dm/src/index.ts b/packages/connectors/connector-aliyun-dm/src/index.ts
index 25bb9c513..4fbd8865c 100644
--- a/packages/connectors/connector-aliyun-dm/src/index.ts
+++ b/packages/connectors/connector-aliyun-dm/src/index.ts
@@ -13,6 +13,7 @@ import {
   ConnectorType,
   validateConfig,
   parseJson,
+  replaceSendMessageHandlebars,
 } from '@logto/connector-kit';
 
 import { defaultMetadata } from './constant.js';
@@ -49,11 +50,8 @@ const sendMessage =
           AddressType: '1',
           ToAddress: to,
           FromAlias: fromAlias,
-          Subject: template.subject,
-          HtmlBody:
-            typeof payload.code === 'string'
-              ? template.content.replaceAll('{{code}}', payload.code)
-              : template.content,
+          Subject: replaceSendMessageHandlebars(template.subject, payload),
+          HtmlBody: replaceSendMessageHandlebars(template.content, payload),
         },
         accessKeySecret
       );
diff --git a/packages/connectors/connector-aliyun-dm/src/mock.ts b/packages/connectors/connector-aliyun-dm/src/mock.ts
index 18d4dec27..891f3f689 100644
--- a/packages/connectors/connector-aliyun-dm/src/mock.ts
+++ b/packages/connectors/connector-aliyun-dm/src/mock.ts
@@ -35,23 +35,28 @@ export const mockedConfigWithAllRequiredTemplates = {
   templates: [
     {
       usageType: 'SignIn',
-      content: 'Your code is {{code}}, {{code}} is your code',
-      subject: 'subject',
+      content: 'Your sign-in code is {{code}}, {{code}} is your code',
+      subject: 'Sign-in code {{code}}',
     },
     {
       usageType: 'Register',
-      content: 'Your code is {{code}}, {{code}} is your code',
+      content: 'Your register code is {{code}}, {{code}} is your code',
       subject: 'subject',
     },
     {
       usageType: 'ForgotPassword',
-      content: 'Your code is {{code}}, {{code}} is your code',
+      content: 'Your forgot password code is {{code}}, {{code}} is your code',
       subject: 'subject',
     },
     {
       usageType: 'Generic',
-      content: 'Your code is {{code}}, {{code}} is your code',
+      content: 'Your generic code is {{code}}, {{code}} is your code',
       subject: 'subject',
     },
+    {
+      usageType: 'OrganizationInvitation',
+      content: 'Your link is {{link}}',
+      subject: 'Organization invitation',
+    },
   ],
 };
diff --git a/packages/connectors/connector-aliyun-sms/src/index.test.ts b/packages/connectors/connector-aliyun-sms/src/index.test.ts
index 13e776804..10dba3ed0 100644
--- a/packages/connectors/connector-aliyun-sms/src/index.test.ts
+++ b/packages/connectors/connector-aliyun-sms/src/index.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import { mockedConnectorConfig, phoneTest, codeTest } from './mock.js';
 
@@ -26,7 +26,7 @@ describe('sendMessage()', () => {
     const connector = await createConnector({ getConfig });
     await connector.sendMessage({
       to: phoneTest,
-      type: VerificationCodeType.SignIn,
+      type: TemplateType.SignIn,
       payload: { code: codeTest },
     });
     expect(sendSms).toHaveBeenCalledWith(
@@ -48,7 +48,7 @@ describe('sendMessage()', () => {
       // eslint-disable-next-line no-await-in-loop
       await connector.sendMessage({
         to,
-        type: VerificationCodeType.Register,
+        type: TemplateType.Register,
         payload: { code: codeTest },
       });
       expect(sendSms).toHaveBeenCalledWith(
@@ -63,7 +63,7 @@ describe('sendMessage()', () => {
 
     await connector.sendMessage({
       to: '+1123123123',
-      type: VerificationCodeType.Register,
+      type: TemplateType.Register,
       payload: { code: codeTest },
     });
     expect(sendSms).toHaveBeenCalledWith(
diff --git a/packages/connectors/connector-aws-ses/src/index.test.ts b/packages/connectors/connector-aws-ses/src/index.test.ts
index d2df1df2d..f95720a70 100644
--- a/packages/connectors/connector-aws-ses/src/index.test.ts
+++ b/packages/connectors/connector-aws-ses/src/index.test.ts
@@ -1,5 +1,5 @@
 import { SESv2Client } from '@aws-sdk/client-sesv2';
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import createConnector from './index.js';
 import { mockedConfig } from './mock.js';
@@ -20,13 +20,13 @@ describe('sendMessage()', () => {
     jest.clearAllMocks();
   });
 
-  it('should call SendMail() and replace code in content', async () => {
+  it('should call SendMail() with correct template and content', async () => {
     const connector = await createConnector({ getConfig });
     const toMail = 'to@email.com';
     const { emailAddress } = mockedConfig;
     await connector.sendMessage({
       to: toMail,
-      type: VerificationCodeType.SignIn,
+      type: TemplateType.SignIn,
       payload: { code: '1234' },
     });
     const toExpected = [toMail];
@@ -37,7 +37,7 @@ describe('sendMessage()', () => {
           Destination: { ToAddresses: toExpected },
           Content: {
             Simple: {
-              Subject: { Data: 'subject', Charset: 'utf8' },
+              Subject: { Data: 'Sign-in code 1234', Charset: 'utf8' },
               Body: {
                 Html: {
                   Data: 'Your code is 1234, 1234 is your code',
@@ -53,4 +53,38 @@ describe('sendMessage()', () => {
       })
     );
   });
+
+  it('should call SendMail() with correct template and content (2)', async () => {
+    const connector = await createConnector({ getConfig });
+    const toMail = 'to@email.com';
+    const { emailAddress } = mockedConfig;
+    await connector.sendMessage({
+      to: toMail,
+      type: TemplateType.OrganizationInvitation,
+      payload: { code: '1234', link: 'https://logto.dev' },
+    });
+    const toExpected = [toMail];
+    expect(SESv2Client.prototype.send).toHaveBeenCalledWith(
+      expect.objectContaining({
+        input: {
+          FromEmailAddress: emailAddress,
+          Destination: { ToAddresses: toExpected },
+          Content: {
+            Simple: {
+              Subject: { Data: 'Organization invitation', Charset: 'utf8' },
+              Body: {
+                Html: {
+                  Data: 'Your link is https://logto.dev',
+                },
+              },
+            },
+          },
+          FeedbackForwardingEmailAddress: undefined,
+          FeedbackForwardingEmailAddressIdentityArn: undefined,
+          FromEmailAddressIdentityArn: undefined,
+          ConfigurationSetName: undefined,
+        },
+      })
+    );
+  });
 });
diff --git a/packages/connectors/connector-aws-ses/src/mock.ts b/packages/connectors/connector-aws-ses/src/mock.ts
index d2577a873..c71398b03 100644
--- a/packages/connectors/connector-aws-ses/src/mock.ts
+++ b/packages/connectors/connector-aws-ses/src/mock.ts
@@ -7,22 +7,27 @@ export const mockedConfig = {
     {
       usageType: 'SignIn',
       content: 'Your code is {{code}}, {{code}} is your code',
-      subject: 'subject',
+      subject: 'Sign-in code {{code}}',
     },
     {
       usageType: 'Register',
-      content: 'Your code is {{code}}, {{code}} is your code',
+      content: 'Your register code is {{code}}, {{code}} is your code',
       subject: 'subject',
     },
     {
       usageType: 'ForgotPassword',
-      content: 'Your code is {{code}}, {{code}} is your code',
+      content: 'Your forgot password code is {{code}}, {{code}} is your code',
       subject: 'subject',
     },
     {
       usageType: 'Generic',
-      content: 'Your code is {{code}}, {{code}} is your code',
+      content: 'Your generic code is {{code}}, {{code}} is your code',
       subject: 'subject',
     },
+    {
+      usageType: 'OrganizationInvitation',
+      content: 'Your link is {{link}}',
+      subject: 'Organization invitation',
+    },
   ],
 };
diff --git a/packages/connectors/connector-aws-ses/src/types.ts b/packages/connectors/connector-aws-ses/src/types.ts
index 8f63eb983..e0c5ac65e 100644
--- a/packages/connectors/connector-aws-ses/src/types.ts
+++ b/packages/connectors/connector-aws-ses/src/types.ts
@@ -38,7 +38,3 @@ export const awsSesConfigGuard = z.object({
 });
 
 export type AwsSesConfig = z.infer<typeof awsSesConfigGuard>;
-
-export type Payload = {
-  code: string | number;
-};
diff --git a/packages/connectors/connector-aws-ses/src/utils.ts b/packages/connectors/connector-aws-ses/src/utils.ts
index c059acc9d..a4c24c8bf 100644
--- a/packages/connectors/connector-aws-ses/src/utils.ts
+++ b/packages/connectors/connector-aws-ses/src/utils.ts
@@ -1,8 +1,9 @@
 import type { EmailContent } from '@aws-sdk/client-sesv2';
 import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2';
 import type { AwsCredentialIdentity } from '@aws-sdk/types';
+import { replaceSendMessageHandlebars, type SendMessagePayload } from '@logto/connector-kit';
 
-import type { AwsSesConfig, Template, Payload } from './types.js';
+import type { AwsSesConfig, Template } from './types.js';
 
 export const makeClient = (
   accessKeyId: string,
@@ -17,16 +18,13 @@ export const makeClient = (
   return new SESv2Client({ credentials, region });
 };
 
-export const makeEmailContent = (template: Template, payload: Payload): EmailContent => {
+export const makeEmailContent = (template: Template, payload: SendMessagePayload): EmailContent => {
   return {
     Simple: {
-      Subject: { Data: template.subject, Charset: 'utf8' },
+      Subject: { Data: replaceSendMessageHandlebars(template.subject, payload), Charset: 'utf8' },
       Body: {
         Html: {
-          Data:
-            typeof payload.code === 'string'
-              ? template.content.replaceAll('{{code}}', payload.code)
-              : template.content,
+          Data: replaceSendMessageHandlebars(template.content, payload),
         },
       },
     },
diff --git a/packages/connectors/connector-logto-email/src/index.test.ts b/packages/connectors/connector-logto-email/src/index.test.ts
index e230360d4..e38af0d89 100644
--- a/packages/connectors/connector-logto-email/src/index.test.ts
+++ b/packages/connectors/connector-logto-email/src/index.test.ts
@@ -1,7 +1,7 @@
 import { got } from 'got';
 import nock from 'nock';
 
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import { emailEndpoint, usageEndpoint } from './constant.js';
 import createConnector from './index.js';
@@ -38,7 +38,7 @@ describe('sendMessage()', () => {
     await expect(
       sendMessage({
         to: 'wangsijie94@gmail.com',
-        type: VerificationCodeType.SignIn,
+        type: TemplateType.SignIn,
         payload: { code: '1234' },
       })
     ).resolves.not.toThrow();
diff --git a/packages/connectors/connector-logto-email/src/index.ts b/packages/connectors/connector-logto-email/src/index.ts
index 1a7451bd6..73fcc7344 100644
--- a/packages/connectors/connector-logto-email/src/index.ts
+++ b/packages/connectors/connector-logto-email/src/index.ts
@@ -38,6 +38,7 @@ const sendMessage =
     try {
       await client.post(`/api${emailEndpoint}`, {
         body: {
+          // @ts-expect-error TODO: @gao update cloud types and remove this
           data: { to, type, payload: { ...payload, senderName, companyInformation, appLogo } },
         },
       });
diff --git a/packages/connectors/connector-logto-sms/src/index.test.ts b/packages/connectors/connector-logto-sms/src/index.test.ts
index 6d73ddbed..740d7b34f 100644
--- a/packages/connectors/connector-logto-sms/src/index.test.ts
+++ b/packages/connectors/connector-logto-sms/src/index.test.ts
@@ -1,6 +1,6 @@
 import nock from 'nock';
 
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import { smsEndpoint } from './constant.js';
 import { mockedAccessTokenResponse, mockedConfig } from './mock.js';
@@ -22,7 +22,7 @@ describe('sendMessage()', () => {
     await expect(
       connector.sendMessage({
         to: '13000000000',
-        type: VerificationCodeType.SignIn,
+        type: TemplateType.SignIn,
         payload: { code: '1234' },
       })
     ).resolves.not.toThrow();
diff --git a/packages/connectors/connector-mailgun/src/index.test.ts b/packages/connectors/connector-mailgun/src/index.test.ts
index 5d000bd0a..50468ce53 100644
--- a/packages/connectors/connector-mailgun/src/index.test.ts
+++ b/packages/connectors/connector-mailgun/src/index.test.ts
@@ -1,6 +1,6 @@
 import nock from 'nock';
 
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import createMailgunConnector from './index.js';
 import { type MailgunConfig } from './types.js';
@@ -63,7 +63,7 @@ describe('Maligun connector', () => {
     getConfig.mockResolvedValue({
       ...baseConfig,
       deliveries: {
-        [VerificationCodeType.Generic]: {
+        [TemplateType.Generic]: {
           subject: 'Verification code is {{code}}',
           html: '<p>Your verification code is {{code}}</p>',
           replyTo: 'baz@example.com',
@@ -73,11 +73,38 @@ describe('Maligun connector', () => {
 
     await connector.sendMessage({
       to: 'bar@example.com',
-      type: VerificationCodeType.Generic,
+      type: TemplateType.Generic,
       payload: { code: '123456' },
     });
   });
 
+  it('should send email with raw data (2)', async () => {
+    nockMessages({
+      from: baseConfig.from,
+      to: 'bar@example.com',
+      subject: 'Organization invitation',
+      html: '<p>Your link is https://example.com</p>',
+      'h:Reply-To': 'baz@example.com',
+    });
+
+    getConfig.mockResolvedValue({
+      ...baseConfig,
+      deliveries: {
+        [TemplateType.OrganizationInvitation]: {
+          subject: 'Organization invitation',
+          html: '<p>Your link is {{link}}</p>',
+          replyTo: 'baz@example.com',
+        },
+      },
+    });
+
+    await connector.sendMessage({
+      to: 'bar@example.com',
+      type: TemplateType.OrganizationInvitation,
+      payload: { link: 'https://example.com', code: '123456' },
+    });
+  });
+
   it('should send email with template', async () => {
     nockMessages({
       from: 'foo@example.com',
@@ -90,7 +117,7 @@ describe('Maligun connector', () => {
     getConfig.mockResolvedValue({
       ...baseConfig,
       deliveries: {
-        [VerificationCodeType.Generic]: {
+        [TemplateType.Generic]: {
           template: 'template',
           variables: { foo: 'bar' },
           subject: 'Verification code is {{code}}',
@@ -100,7 +127,7 @@ describe('Maligun connector', () => {
 
     await connector.sendMessage({
       to: 'bar@example.com',
-      type: VerificationCodeType.Generic,
+      type: TemplateType.Generic,
       payload: {
         code: '123456',
       },
@@ -123,7 +150,7 @@ describe('Maligun connector', () => {
       ...baseConfig,
       endpoint: 'https://api.eu.mailgun.net',
       deliveries: {
-        [VerificationCodeType.Generic]: {
+        [TemplateType.Generic]: {
           template: 'template',
           variables: { foo: 'bar' },
           subject: 'Verification code is {{code}}',
@@ -133,7 +160,7 @@ describe('Maligun connector', () => {
 
     await connector.sendMessage({
       to: 'bar@example.com',
-      type: VerificationCodeType.Generic,
+      type: TemplateType.Generic,
       payload: {
         code: '123456',
       },
@@ -152,7 +179,7 @@ describe('Maligun connector', () => {
     getConfig.mockResolvedValue({
       ...baseConfig,
       deliveries: {
-        [VerificationCodeType.Generic]: {
+        [TemplateType.Generic]: {
           template: 'template',
           variables: { foo: 'bar' },
           subject: 'Verification code is {{code}}',
@@ -162,7 +189,7 @@ describe('Maligun connector', () => {
 
     await connector.sendMessage({
       to: 'bar@example.com',
-      type: VerificationCodeType.ForgotPassword,
+      type: TemplateType.ForgotPassword,
       payload: {
         code: '123456',
       },
@@ -178,7 +205,7 @@ describe('Maligun connector', () => {
     await expect(
       connector.sendMessage({
         to: '',
-        type: VerificationCodeType.Generic,
+        type: TemplateType.Generic,
         payload: {
           code: '123456',
         },
@@ -194,14 +221,14 @@ describe('Maligun connector', () => {
           code: '123456',
         },
       })
-    ).rejects.toThrowErrorMatchingInlineSnapshot('"ConnectorError: template_not_supported"');
+    ).rejects.toThrowErrorMatchingInlineSnapshot('"ConnectorError: template_not_found"');
   });
 
   it('should throw error if mailgun returns error', async () => {
     getConfig.mockResolvedValue({
       ...baseConfig,
       deliveries: {
-        [VerificationCodeType.Generic]: {
+        [TemplateType.Generic]: {
           template: 'template',
           variables: { foo: 'bar' },
           subject: 'Verification code is {{code}}',
@@ -214,7 +241,7 @@ describe('Maligun connector', () => {
     await expect(
       connector.sendMessage({
         to: '',
-        type: VerificationCodeType.Generic,
+        type: TemplateType.Generic,
         payload: {
           code: '123456',
         },
diff --git a/packages/connectors/connector-mailgun/src/index.ts b/packages/connectors/connector-mailgun/src/index.ts
index 4df212ab4..e54082817 100644
--- a/packages/connectors/connector-mailgun/src/index.ts
+++ b/packages/connectors/connector-mailgun/src/index.ts
@@ -5,27 +5,29 @@ import type {
   SendMessageFunction,
   CreateConnector,
   EmailConnector,
+  SendMessagePayload,
 } from '@logto/connector-kit';
 import {
   ConnectorError,
   ConnectorErrorCodes,
   ConnectorType,
   validateConfig,
-  VerificationCodeType,
+  TemplateType,
+  replaceSendMessageHandlebars,
 } from '@logto/connector-kit';
 
 import { defaultMetadata } from './constant.js';
-import { type DeliveryConfig, mailgunConfigGuard, supportTemplateGuard } from './types.js';
+import { type DeliveryConfig, mailgunConfigGuard } from './types.js';
 
 const removeUndefinedKeys = (object: Record<string, unknown>) =>
   Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
 
 const getDataFromDeliveryConfig = (
   { subject, replyTo, ...rest }: DeliveryConfig,
-  code: string
+  payload: SendMessagePayload
 ): Record<string, string | undefined> => {
   const commonData = {
-    subject: subject?.replaceAll('{{code}}', code),
+    subject: subject && replaceSendMessageHandlebars(subject, payload),
     'h:Reply-To': replyTo,
   };
 
@@ -33,30 +35,24 @@ const getDataFromDeliveryConfig = (
     return {
       ...commonData,
       template: rest.template,
-      'h:X-Mailgun-Variables': JSON.stringify({ ...rest.variables, code }),
+      'h:X-Mailgun-Variables': JSON.stringify({ ...rest.variables, ...payload }),
     };
   }
 
   return {
     ...commonData,
-    html: rest.html.replaceAll('{{code}}', code),
-    text: rest.text?.replaceAll('{{code}}', code),
+    html: replaceSendMessageHandlebars(rest.html, payload),
+    text: rest.text && replaceSendMessageHandlebars(rest.text, payload),
   };
 };
 
 const sendMessage = (getConfig: GetConnectorConfig): SendMessageFunction => {
-  return async ({ to, type: typeInput, payload: { code } }, inputConfig) => {
+  return async ({ to, type, payload }, inputConfig) => {
     const config = inputConfig ?? (await getConfig(defaultMetadata.id));
     validateConfig(config, mailgunConfigGuard);
 
     const { endpoint, domain, apiKey, from, deliveries } = config;
-    const type = supportTemplateGuard.safeParse(typeInput);
-
-    if (!type.success) {
-      throw new ConnectorError(ConnectorErrorCodes.TemplateNotSupported);
-    }
-
-    const template = deliveries[type.data] ?? deliveries[VerificationCodeType.Generic];
+    const template = deliveries[type] ?? deliveries[TemplateType.Generic];
 
     if (!template) {
       throw new ConnectorError(ConnectorErrorCodes.TemplateNotFound);
@@ -71,7 +67,7 @@ const sendMessage = (getConfig: GetConnectorConfig): SendMessageFunction => {
           form: {
             from,
             to,
-            ...removeUndefinedKeys(getDataFromDeliveryConfig(template, code)),
+            ...removeUndefinedKeys(getDataFromDeliveryConfig(template, payload)),
           },
         }
       );
diff --git a/packages/connectors/connector-mailgun/src/type.test.ts b/packages/connectors/connector-mailgun/src/type.test.ts
index 2aa00f830..a5c9a8648 100644
--- a/packages/connectors/connector-mailgun/src/type.test.ts
+++ b/packages/connectors/connector-mailgun/src/type.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import { mailgunConfigGuard } from './types.js';
 
@@ -9,21 +9,21 @@ describe('Mailgun config guard', () => {
       apiKey: 'key',
       from: 'from',
       deliveries: {
-        [VerificationCodeType.SignIn]: {
+        [TemplateType.SignIn]: {
           html: 'html',
           subject: 'subject',
         },
-        [VerificationCodeType.Register]: {
+        [TemplateType.Register]: {
           template: 'template',
           variables: {},
           subject: 'subject',
         },
-        [VerificationCodeType.ForgotPassword]: {
+        [TemplateType.ForgotPassword]: {
           html: 'html',
           text: 'text',
           subject: 'subject',
         },
-        [VerificationCodeType.Generic]: {
+        [TemplateType.Generic]: {
           template: 'template',
           variables: {},
           subject: 'subject',
@@ -39,7 +39,7 @@ describe('Mailgun config guard', () => {
       apiKey: 'key',
       from: 'from',
       deliveries: {
-        [VerificationCodeType.SignIn]: {
+        [TemplateType.SignIn]: {
           html: 'html',
           subject: 'subject',
         },
@@ -54,7 +54,7 @@ describe('Mailgun config guard', () => {
       apiKey: 'key',
       from: 'from',
       deliveries: {
-        [VerificationCodeType.ForgotPassword]: {
+        [TemplateType.ForgotPassword]: {
           text: 'text',
           subject: 'subject',
         },
@@ -70,7 +70,7 @@ describe('Mailgun config guard', () => {
       apiKey: 'key',
       from: 'from',
       deliveries: {
-        [VerificationCodeType.ForgotPassword]: {
+        [TemplateType.ForgotPassword]: {
           html: 'html',
           subject: 'subject',
         },
diff --git a/packages/connectors/connector-mailgun/src/types.ts b/packages/connectors/connector-mailgun/src/types.ts
index 4fd8c50f1..95a6f6025 100644
--- a/packages/connectors/connector-mailgun/src/types.ts
+++ b/packages/connectors/connector-mailgun/src/types.ts
@@ -1,16 +1,5 @@
 import { z } from 'zod';
 
-import { VerificationCodeType } from '@logto/connector-kit';
-
-export const supportTemplateGuard = z.enum([
-  VerificationCodeType.SignIn,
-  VerificationCodeType.Register,
-  VerificationCodeType.ForgotPassword,
-  VerificationCodeType.Generic,
-]);
-
-type SupportTemplate = z.infer<typeof supportTemplateGuard>;
-
 type CommonEmailConfig = {
   /** Subject of the message. */
   subject?: string;
@@ -65,7 +54,7 @@ export type MailgunConfig = {
    * The template config object for each template type, while the key is the template type
    * and the value is the config object.
    */
-  deliveries: Partial<Record<SupportTemplate, DeliveryConfig>>;
+  deliveries: Record<string, DeliveryConfig>;
 };
 
 export const mailgunConfigGuard = z.object({
@@ -73,7 +62,5 @@ export const mailgunConfigGuard = z.object({
   domain: z.string(),
   apiKey: z.string(),
   from: z.string(),
-  // Although the type it's expected, this guard should infer required keys. Looks like a mis-implemented in zod.
-  // See https://github.com/colinhacks/zod/issues/2623
-  deliveries: z.record(supportTemplateGuard, templateConfigGuard),
+  deliveries: z.record(templateConfigGuard),
 }) satisfies z.ZodType<MailgunConfig>;
diff --git a/packages/connectors/connector-sendgrid-email/src/index.test.ts b/packages/connectors/connector-sendgrid-email/src/index.test.ts
index 8a5dd446a..9f518865c 100644
--- a/packages/connectors/connector-sendgrid-email/src/index.test.ts
+++ b/packages/connectors/connector-sendgrid-email/src/index.test.ts
@@ -9,4 +9,6 @@ describe('SendGrid connector', () => {
   it('init without throwing errors', async () => {
     await expect(createConnector({ getConfig })).resolves.not.toThrow();
   });
+
+  // TODO: add test cases
 });
diff --git a/packages/connectors/connector-sendgrid-email/src/index.ts b/packages/connectors/connector-sendgrid-email/src/index.ts
index 3b1ec7e44..4ba0c6837 100644
--- a/packages/connectors/connector-sendgrid-email/src/index.ts
+++ b/packages/connectors/connector-sendgrid-email/src/index.ts
@@ -12,6 +12,7 @@ import {
   ConnectorErrorCodes,
   validateConfig,
   ConnectorType,
+  replaceSendMessageHandlebars,
 } from '@logto/connector-kit';
 
 import { defaultMetadata, endpoint } from './constant.js';
@@ -42,17 +43,13 @@ const sendMessage =
     const personalizations: Personalization = { to: toEmailData };
     const content: Content = {
       type: template.type,
-      value:
-        typeof payload.code === 'string'
-          ? template.content.replaceAll('{{code}}', payload.code)
-          : template.content,
+      value: replaceSendMessageHandlebars(template.content, payload),
     };
-    const { subject } = template;
 
     const parameters: PublicParameters = {
       personalizations: [personalizations],
       from: fromEmailData,
-      subject,
+      subject: replaceSendMessageHandlebars(template.subject, payload),
       content: [content],
     };
 
diff --git a/packages/connectors/connector-smsaero/src/index.test.ts b/packages/connectors/connector-smsaero/src/index.test.ts
index f2a33ba12..253b7ada7 100644
--- a/packages/connectors/connector-smsaero/src/index.test.ts
+++ b/packages/connectors/connector-smsaero/src/index.test.ts
@@ -9,4 +9,6 @@ describe('SMSAero SMS connector', () => {
   it('init without throwing errors', async () => {
     await expect(createConnector({ getConfig })).resolves.not.toThrow();
   });
+
+  // TODO: add test cases
 });
diff --git a/packages/connectors/connector-smsaero/src/index.ts b/packages/connectors/connector-smsaero/src/index.ts
index 66a6bbc9b..95465b055 100644
--- a/packages/connectors/connector-smsaero/src/index.ts
+++ b/packages/connectors/connector-smsaero/src/index.ts
@@ -11,6 +11,7 @@ import {
   ConnectorError,
   ConnectorErrorCodes,
   ConnectorType,
+  replaceSendMessageHandlebars,
   validateConfig,
 } from '@logto/connector-kit';
 
@@ -40,7 +41,7 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction {
     const parameters: PublicParameters = {
       number: to,
       sign: senderName,
-      text: template.content.replaceAll('{{code}}', payload.code),
+      text: replaceSendMessageHandlebars(template.content, payload),
     };
 
     const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
diff --git a/packages/connectors/connector-smtp/src/index.test.ts b/packages/connectors/connector-smtp/src/index.test.ts
index 51326deb4..ad4426045 100644
--- a/packages/connectors/connector-smtp/src/index.test.ts
+++ b/packages/connectors/connector-smtp/src/index.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import type { Transporter } from 'nodemailer';
 import nodemailer from 'nodemailer';
 
@@ -38,7 +38,7 @@ describe('SMTP connector', () => {
     const connector = await createConnector({ getConfig });
     await connector.sendMessage({
       to: 'foo',
-      type: VerificationCodeType.Register,
+      type: TemplateType.Register,
       payload: { code: '123456' },
     });
 
@@ -50,11 +50,11 @@ describe('SMTP connector', () => {
     });
   });
 
-  it('should send mail with proper subject', async () => {
+  it('should send mail with proper data', async () => {
     const connector = await createConnector({ getConfig });
     await connector.sendMessage({
       to: 'bar',
-      type: VerificationCodeType.SignIn,
+      type: TemplateType.SignIn,
       payload: { code: '234567' },
     });
 
@@ -65,6 +65,22 @@ describe('SMTP connector', () => {
       to: 'bar',
     });
   });
+
+  it('should send mail with proper data (2)', async () => {
+    const connector = await createConnector({ getConfig });
+    await connector.sendMessage({
+      to: 'baz',
+      type: TemplateType.OrganizationInvitation,
+      payload: { code: '345678', link: 'https://example.com' },
+    });
+
+    expect(sendMail).toHaveBeenCalledWith({
+      from: '<notice@test.smtp>',
+      subject: 'Organization invitation',
+      text: 'This is for organization invitation. Your link is https://example.com.',
+      to: 'baz',
+    });
+  });
 });
 
 describe('Test config guard', () => {
diff --git a/packages/connectors/connector-smtp/src/index.ts b/packages/connectors/connector-smtp/src/index.ts
index 0cc2b556b..1517923fd 100644
--- a/packages/connectors/connector-smtp/src/index.ts
+++ b/packages/connectors/connector-smtp/src/index.ts
@@ -11,6 +11,7 @@ import {
   ConnectorErrorCodes,
   validateConfig,
   ConnectorType,
+  replaceSendMessageHandlebars,
 } from '@logto/connector-kit';
 import nodemailer from 'nodemailer';
 import type SMTPTransport from 'nodemailer/lib/smtp-transport';
@@ -39,9 +40,7 @@ const sendMessage =
     const transporter = nodemailer.createTransport(configOptions);
 
     const contentsObject = parseContents(
-      typeof payload.code === 'string'
-        ? template.content.replaceAll(/{{\s*code\s*}}/g, payload.code)
-        : template.content,
+      replaceSendMessageHandlebars(template.content, payload),
       template.contentType
     );
 
@@ -49,7 +48,7 @@ const sendMessage =
       to,
       from: config.fromEmail,
       replyTo: config.replyTo,
-      subject: template.subject.replaceAll(/{{\s*code\s*}}/g, payload.code),
+      subject: replaceSendMessageHandlebars(template.subject, payload),
       ...contentsObject,
     };
 
diff --git a/packages/connectors/connector-smtp/src/mock.ts b/packages/connectors/connector-smtp/src/mock.ts
index 16dae9729..87f2017d3 100644
--- a/packages/connectors/connector-smtp/src/mock.ts
+++ b/packages/connectors/connector-smtp/src/mock.ts
@@ -28,6 +28,12 @@ export const mockedConfig = {
       subject: 'Logto Forgot Password with SMTP',
       usageType: 'ForgotPassword',
     },
+    {
+      contentType: 'text/plain',
+      content: 'This is for organization invitation. Your link is {{  link}}.',
+      subject: 'Organization invitation',
+      usageType: 'OrganizationInvitation',
+    },
   ],
 };
 
diff --git a/packages/connectors/connector-tencent-sms/src/index.test.ts b/packages/connectors/connector-tencent-sms/src/index.test.ts
index 4c58dec68..a30b51fed 100644
--- a/packages/connectors/connector-tencent-sms/src/index.test.ts
+++ b/packages/connectors/connector-tencent-sms/src/index.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 
 import { codeTest, mockedConnectorConfig, mockedTemplateCode, phoneTest } from './mock.js';
 
@@ -47,7 +47,7 @@ describe('sendMessage()', () => {
     const connector = await createConnector({ getConfig });
     await connector.sendMessage({
       to: phoneTest,
-      type: VerificationCodeType.SignIn,
+      type: TemplateType.SignIn,
       payload: { code: codeTest },
     });
     expect(sendSmsRequest).toHaveBeenCalledWith(
diff --git a/packages/connectors/connector-tencent-sms/src/index.ts b/packages/connectors/connector-tencent-sms/src/index.ts
index 1d6fe34cc..dc072ffcf 100644
--- a/packages/connectors/connector-tencent-sms/src/index.ts
+++ b/packages/connectors/connector-tencent-sms/src/index.ts
@@ -46,7 +46,7 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction {
     );
 
     try {
-      const httpResponse = await sendSmsRequest(template.templateCode, [payload.code], to, {
+      const httpResponse = await sendSmsRequest(template.templateCode, Object.values(payload), to, {
         secretId: accessKeyId,
         secretKey: accessKeySecret,
         sdkAppId,
diff --git a/packages/connectors/connector-twilio-sms/src/index.ts b/packages/connectors/connector-twilio-sms/src/index.ts
index a5e00b192..3036cc10e 100644
--- a/packages/connectors/connector-twilio-sms/src/index.ts
+++ b/packages/connectors/connector-twilio-sms/src/index.ts
@@ -12,6 +12,7 @@ import {
   ConnectorErrorCodes,
   validateConfig,
   ConnectorType,
+  replaceSendMessageHandlebars,
 } from '@logto/connector-kit';
 
 import { defaultMetadata, endpoint } from './constant.js';
@@ -38,10 +39,7 @@ const sendMessage =
     const parameters: PublicParameters = {
       To: to,
       MessagingServiceSid: fromMessagingServiceSID,
-      Body:
-        typeof payload.code === 'string'
-          ? template.content.replaceAll('{{code}}', payload.code)
-          : template.content,
+      Body: replaceSendMessageHandlebars(template.content, payload),
     };
 
     try {
diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts
index e92f3cbad..2d3203a1e 100644
--- a/packages/core/src/__mocks__/index.ts
+++ b/packages/core/src/__mocks__/index.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import type {
   AdminConsoleData,
   Application,
@@ -178,7 +178,7 @@ export const mockPasscode: Passcode = {
   interactionJti: 'jti',
   phone: '888 888 8888',
   email: 'foo@logto.io',
-  type: VerificationCodeType.SignIn,
+  type: TemplateType.SignIn,
   code: 'asdfghjkl',
   consumed: false,
   tryCount: 2,
diff --git a/packages/core/src/libraries/connector.ts b/packages/core/src/libraries/connector.ts
index 4333ed4de..42c725081 100644
--- a/packages/core/src/libraries/connector.ts
+++ b/packages/core/src/libraries/connector.ts
@@ -1,5 +1,10 @@
 import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
-import type { AllConnector, ConnectorPlatform } from '@logto/connector-kit';
+import type {
+  AllConnector,
+  ConnectorPlatform,
+  EmailConnector,
+  SmsConnector,
+} from '@logto/connector-kit';
 import { validateConfig, ServiceConnector, ConnectorType } from '@logto/connector-kit';
 import { type Nullable, conditional, pick, trySafe } from '@silverhand/essentials';
 
@@ -134,11 +139,44 @@ export const createConnectorLibrary = (
     });
   };
 
+  /** Type of the connector that can send message of the given type. */
+  type MappedConnectorType = {
+    [ConnectorType.Email]: LogtoConnector<EmailConnector>;
+    [ConnectorType.Sms]: LogtoConnector<SmsConnector>;
+  };
+
+  const getMessageConnector = async <Type extends keyof MappedConnectorType>(
+    type: Type
+  ): Promise<MappedConnectorType[Type]> => {
+    const connectors = await getLogtoConnectors();
+    const connector = connectors.find(
+      (connector): connector is MappedConnectorType[Type] => connector.type === type
+    );
+    assertThat(
+      connector,
+      // TODO: @gao refactor RequestError and ServerError to share the same base class
+      new RequestError({
+        code: 'connector.not_found',
+        type,
+        status: 501,
+      })
+    );
+    return connector;
+  };
+
   return {
     getConnectorConfig,
     getLogtoConnectors,
     getLogtoConnectorsWellKnown,
     getLogtoConnectorById,
     getLogtoConnectorByTargetAndPlatform,
+    /**
+     * Get the connector that can send message of the given type.
+     *
+     * @param type The type of the connector to get.
+     * @returns The connector that can send message of the given type.
+     * @throws {RequestError} If no connector can send message of the given type (status 500).
+     */
+    getMessageConnector,
   };
 };
diff --git a/packages/core/src/libraries/organization-invitation.ts b/packages/core/src/libraries/organization-invitation.ts
new file mode 100644
index 000000000..681d81a01
--- /dev/null
+++ b/packages/core/src/libraries/organization-invitation.ts
@@ -0,0 +1,90 @@
+import { ConnectorType, TemplateType } from '@logto/connector-kit';
+import { OrganizationInvitationStatus, type CreateOrganizationInvitation } from '@logto/schemas';
+import { generateStandardId } from '@logto/shared';
+import { appendPath } from '@silverhand/essentials';
+
+import { EnvSet } from '#src/env-set/index.js';
+import { getTenantEndpoint } from '#src/env-set/utils.js';
+import MagicLinkQueries from '#src/queries/magic-link.js';
+import OrganizationQueries from '#src/queries/organization/index.js';
+import type Queries from '#src/tenants/Queries.js';
+
+import { type ConnectorLibrary } from './connector.js';
+
+const invitationLinkPath = '/invitation';
+
+/** Class for managing organization invitations. */
+export class OrganizationInvitationLibrary {
+  constructor(
+    protected readonly tenantId: string,
+    protected readonly queries: Queries,
+    protected readonly connector: ConnectorLibrary
+  ) {}
+
+  /**
+   * Creates a new organization invitation.
+   *
+   * Note: If the invitation email is not skipped, and the email cannot be sent, the transaction
+   * will be rolled back.
+   *
+   * @param data Invitation data.
+   * @param data.inviterId The user ID of the inviter.
+   * @param data.invitee The email address of the invitee.
+   * @param data.organizationId The ID of the organization to invite to.
+   * @param data.expiresAt The epoch time in milliseconds when the invitation expires.
+   * @param data.organizationRoleIds The IDs of the organization roles to assign to the invitee.
+   * @param skipEmail Whether to skip sending the invitation email. Defaults to `false`.
+   */
+  async insert(
+    data: Pick<
+      CreateOrganizationInvitation,
+      'inviterId' | 'invitee' | 'organizationId' | 'expiresAt'
+    > & { organizationRoleIds?: string[] },
+    skipEmail = false
+  ) {
+    const { inviterId, invitee, organizationId, expiresAt, organizationRoleIds } = data;
+
+    return this.queries.pool.transaction(async (connection) => {
+      const organizationQueries = new OrganizationQueries(connection);
+      const magicLinkQueries = new MagicLinkQueries(connection);
+
+      const magicLink = await magicLinkQueries.insert({
+        id: generateStandardId(),
+        token: generateStandardId(32),
+      });
+      const invitation = await organizationQueries.invitations.insert({
+        id: generateStandardId(),
+        inviterId,
+        invitee,
+        organizationId,
+        magicLinkId: magicLink.id,
+        status: OrganizationInvitationStatus.Pending,
+        expiresAt,
+      });
+
+      if (organizationRoleIds?.length) {
+        await organizationQueries.relations.invitationsRoles.insert(
+          ...organizationRoleIds.map<[string, string]>((roleId) => [invitation.id, roleId])
+        );
+      }
+
+      if (!skipEmail) {
+        await this.sendEmail(invitee, magicLink.token);
+      }
+
+      return invitation;
+    });
+  }
+
+  protected async sendEmail(to: string, token: string) {
+    const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email);
+    return emailConnector.sendMessage({
+      to,
+      type: TemplateType.OrganizationInvitation,
+      payload: {
+        link: appendPath(getTenantEndpoint(this.tenantId, EnvSet.values), invitationLinkPath, token)
+          .href,
+      },
+    });
+  }
+}
diff --git a/packages/core/src/libraries/passcode.test.ts b/packages/core/src/libraries/passcode.test.ts
index 5192a20a6..395900820 100644
--- a/packages/core/src/libraries/passcode.test.ts
+++ b/packages/core/src/libraries/passcode.test.ts
@@ -1,5 +1,5 @@
 import { defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
-import { ConnectorType, VerificationCodeType } from '@logto/connector-kit';
+import { ConnectorType, TemplateType } from '@logto/connector-kit';
 import { type Passcode } from '@logto/schemas';
 import { any } from 'zod';
 
@@ -37,12 +37,12 @@ const {
   consumePasscode,
 } = passcodeQueries;
 
-const getLogtoConnectors = jest.fn();
+const getMessageConnector = jest.fn();
 
 const { createPasscode, sendPasscode, verifyPasscode } = createPasscodeLibrary(
   new MockQueries({ passcodes: passcodeQueries }),
   // @ts-expect-error
-  { getLogtoConnectors }
+  { getMessageConnector }
 );
 
 beforeAll(() => {
@@ -67,7 +67,7 @@ afterEach(() => {
 describe('createPasscode', () => {
   it('should generate `passcodeLength` digits code for phone with valid session and insert to database', async () => {
     const phone = '13000000000';
-    const passcode = await createPasscode('jti', VerificationCodeType.SignIn, {
+    const passcode = await createPasscode('jti', TemplateType.SignIn, {
       phone,
     });
     expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
@@ -76,7 +76,7 @@ describe('createPasscode', () => {
 
   it('should generate `passcodeLength` digits code for email with valid session and insert to database', async () => {
     const email = 'jony@example.com';
-    const passcode = await createPasscode('jti', VerificationCodeType.SignIn, {
+    const passcode = await createPasscode('jti', TemplateType.SignIn, {
       email,
     });
     expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
@@ -85,7 +85,7 @@ describe('createPasscode', () => {
 
   it('should generate `passcodeLength` digits code for phone and insert to database, without session', async () => {
     const phone = '13000000000';
-    const passcode = await createPasscode(undefined, VerificationCodeType.Generic, {
+    const passcode = await createPasscode(undefined, TemplateType.Generic, {
       phone,
     });
     expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
@@ -94,7 +94,7 @@ describe('createPasscode', () => {
 
   it('should generate `passcodeLength` digits code for email and insert to database, without session', async () => {
     const email = 'jony@example.com';
-    const passcode = await createPasscode(undefined, VerificationCodeType.Generic, {
+    const passcode = await createPasscode(undefined, TemplateType.Generic, {
       email,
     });
     expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
@@ -109,7 +109,7 @@ describe('createPasscode', () => {
         id: 'id',
         interactionJti: jti,
         code: '1234',
-        type: VerificationCodeType.SignIn,
+        type: TemplateType.SignIn,
         createdAt: Date.now(),
         phone: '',
         email,
@@ -117,7 +117,7 @@ describe('createPasscode', () => {
         tryCount: 0,
       },
     ]);
-    await createPasscode(jti, VerificationCodeType.SignIn, {
+    await createPasscode(jti, TemplateType.SignIn, {
       email,
     });
     expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']);
@@ -130,7 +130,7 @@ describe('createPasscode', () => {
         id: 'id',
         interactionJti: null,
         code: '123456',
-        type: VerificationCodeType.Generic,
+        type: TemplateType.Generic,
         createdAt: Date.now(),
         phone,
         email: null,
@@ -138,7 +138,7 @@ describe('createPasscode', () => {
         tryCount: 0,
       },
     ]);
-    await createPasscode(undefined, VerificationCodeType.Generic, {
+    await createPasscode(undefined, TemplateType.Generic, {
       phone,
     });
     expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']);
@@ -153,7 +153,7 @@ describe('sendPasscode', () => {
       interactionJti: 'jti',
       phone: null,
       email: null,
-      type: VerificationCodeType.SignIn,
+      type: TemplateType.SignIn,
       code: '1234',
       consumed: false,
       tryCount: 0,
@@ -164,82 +164,29 @@ describe('sendPasscode', () => {
     );
   });
 
-  it('should throw error when email or sms connector can not be found', async () => {
-    getLogtoConnectors.mockResolvedValueOnce([
-      {
-        ...defaultConnectorMethods,
-        dbEntry: {
-          ...mockConnector,
-          id: 'id1',
-        },
-        metadata: {
-          ...mockMetadata,
-          platform: null,
-        },
-        type: ConnectorType.Email,
-        sendMessage: jest.fn(),
-        configGuard: any(),
-      },
-    ]);
-    const passcode: Passcode = {
-      tenantId: 'fake_tenant',
-      id: 'id',
-      interactionJti: 'jti',
-      phone: 'phone',
-      email: null,
-      type: VerificationCodeType.SignIn,
-      code: '1234',
-      consumed: false,
-      tryCount: 0,
-      createdAt: Date.now(),
-    };
-    await expect(sendPasscode(passcode)).rejects.toThrowError(
-      new RequestError({
-        code: 'connector.not_found',
-        type: ConnectorType.Sms,
-      })
-    );
-  });
-
   it('should call sendPasscode with params matching', async () => {
     const sendMessage = jest.fn();
-    getLogtoConnectors.mockResolvedValueOnce([
-      {
-        ...defaultConnectorMethods,
-        configGuard: any(),
-        dbEntry: {
-          ...mockConnector,
-          id: 'id0',
-        },
-        metadata: {
-          ...mockMetadata,
-          platform: null,
-        },
-        type: ConnectorType.Sms,
-        sendMessage,
+    getMessageConnector.mockResolvedValueOnce({
+      ...defaultConnectorMethods,
+      configGuard: any(),
+      dbEntry: {
+        ...mockConnector,
+        id: 'id0',
       },
-      {
-        ...defaultConnectorMethods,
-        configGuard: any(),
-        dbEntry: {
-          ...mockConnector,
-          id: 'id1',
-        },
-        metadata: {
-          ...mockMetadata,
-          platform: null,
-        },
-        type: ConnectorType.Email,
-        sendMessage,
+      metadata: {
+        ...mockMetadata,
+        platform: null,
       },
-    ]);
+      type: ConnectorType.Sms,
+      sendMessage,
+    });
     const passcode: Passcode = {
       tenantId: 'fake_tenant',
       id: 'passcode_id',
       interactionJti: 'jti',
       phone: 'phone',
       email: null,
-      type: VerificationCodeType.SignIn,
+      type: TemplateType.SignIn,
       code: '1234',
       consumed: false,
       tryCount: 0,
@@ -263,7 +210,7 @@ describe('verifyPasscode', () => {
     interactionJti: 'jti',
     phone: 'phone',
     email: null,
-    type: VerificationCodeType.SignIn,
+    type: TemplateType.SignIn,
     code: '1234',
     consumed: false,
     tryCount: 0,
@@ -286,7 +233,7 @@ describe('verifyPasscode', () => {
   it('should mark as consumed on successful verification without jti', async () => {
     const passcodeWithoutJti = {
       ...passcode,
-      type: VerificationCodeType.Generic,
+      type: TemplateType.Generic,
       interactionJti: null,
     };
     findUnconsumedPasscodeByIdentifierAndType.mockResolvedValue(passcodeWithoutJti);
diff --git a/packages/core/src/libraries/passcode.ts b/packages/core/src/libraries/passcode.ts
index b1534b1e6..fce277a9b 100644
--- a/packages/core/src/libraries/passcode.ts
+++ b/packages/core/src/libraries/passcode.ts
@@ -1,18 +1,12 @@
-import type { EmailConnector, VerificationCodeType, SmsConnector } from '@logto/connector-kit';
-import {
-  verificationCodeTypeGuard,
-  ConnectorError,
-  ConnectorErrorCodes,
-} from '@logto/connector-kit';
+import type { TemplateType } from '@logto/connector-kit';
+import { templateTypeGuard, ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
 import type { Passcode } from '@logto/schemas';
 import { customAlphabet, nanoid } from 'nanoid';
 
 import RequestError from '#src/errors/RequestError/index.js';
 import type { ConnectorLibrary } from '#src/libraries/connector.js';
 import type Queries from '#src/tenants/Queries.js';
-import assertThat from '#src/utils/assert-that.js';
 import { ConnectorType } from '#src/utils/connectors/types.js';
-import type { LogtoConnector } from '#src/utils/connectors/types.js';
 
 export const passcodeLength = 6;
 const randomCode = customAlphabet('1234567890', passcodeLength);
@@ -33,11 +27,11 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec
     increasePasscodeTryCount,
     insertPasscode,
   } = queries.passcodes;
-  const { getLogtoConnectors } = connectorLibrary;
+  const { getMessageConnector } = connectorLibrary;
 
   const createPasscode = async (
     jti: string | undefined,
-    type: VerificationCodeType,
+    type: TemplateType,
     payload: { phone: string } | { email: string }
   ) => {
     // Disable existing passcodes.
@@ -68,24 +62,10 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec
     }
 
     const expectType = passcode.phone ? ConnectorType.Sms : ConnectorType.Email;
-    const connectors = await getLogtoConnectors();
-
-    const connector = connectors.find(
-      (connector): connector is LogtoConnector<SmsConnector | EmailConnector> =>
-        connector.type === expectType
-    );
-
-    assertThat(
-      connector,
-      new RequestError({
-        code: 'connector.not_found',
-        type: expectType,
-      })
-    );
-
+    const connector = await getMessageConnector(expectType);
     const { dbEntry, metadata, sendMessage } = connector;
 
-    const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type);
+    const messageTypeResult = templateTypeGuard.safeParse(passcode.type);
 
     if (!messageTypeResult.success) {
       throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
@@ -104,7 +84,7 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec
 
   const verifyPasscode = async (
     jti: string | undefined,
-    type: VerificationCodeType,
+    type: TemplateType,
     code: string,
     payload: { phone: string } | { email: string }
   ): Promise<void> => {
diff --git a/packages/core/src/queries/magic-link.ts b/packages/core/src/queries/magic-link.ts
new file mode 100644
index 000000000..11c9670e9
--- /dev/null
+++ b/packages/core/src/queries/magic-link.ts
@@ -0,0 +1,19 @@
+import {
+  type CreateMagicLink,
+  type MagicLink,
+  type MagicLinkKeys,
+  MagicLinks,
+} from '@logto/schemas';
+import { type CommonQueryMethods } from 'slonik';
+
+import SchemaQueries from '#src/utils/SchemaQueries.js';
+
+export default class MagicLinkQueries extends SchemaQueries<
+  MagicLinkKeys,
+  CreateMagicLink,
+  MagicLink
+> {
+  constructor(pool: CommonQueryMethods) {
+    super(pool, MagicLinks);
+  }
+}
diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts
index df4f909ee..36d377a2e 100644
--- a/packages/core/src/queries/organization/index.ts
+++ b/packages/core/src/queries/organization/index.ts
@@ -195,6 +195,12 @@ export default class OrganizationQueries extends SchemaQueries<
     users: new UserRelationQueries(this.pool),
     /** Queries for organization - organization role - user relations. */
     rolesUsers: new RoleUserRelationQueries(this.pool),
+    invitationsRoles: new TwoRelationsQueries(
+      this.pool,
+      OrganizationInvitationRoleRelations.table,
+      OrganizationInvitations,
+      OrganizationRoles
+    ),
   };
 
   constructor(pool: CommonQueryMethods) {
diff --git a/packages/core/src/queries/passcode.test.ts b/packages/core/src/queries/passcode.test.ts
index 94c6c647e..fb83fc9ab 100644
--- a/packages/core/src/queries/passcode.test.ts
+++ b/packages/core/src/queries/passcode.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import { Passcodes } from '@logto/schemas';
 import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } from '@logto/shared';
 import { createMockPool, createMockQueryResult, sql } from 'slonik';
@@ -35,7 +35,7 @@ describe('passcode query', () => {
 
   it('findUnconsumedPasscodeByJtiAndType', async () => {
     const jti = 'foo';
-    const type = VerificationCodeType.SignIn;
+    const type = TemplateType.SignIn;
 
     const expectSql = sql`
       select ${sql.join(Object.values(fields), sql`, `)}
@@ -55,7 +55,7 @@ describe('passcode query', () => {
 
   it('findUnconsumedPasscodesByJtiAndType', async () => {
     const jti = 'foo';
-    const type = VerificationCodeType.SignIn;
+    const type = TemplateType.SignIn;
 
     const expectSql = sql`
       select ${sql.join(Object.values(fields), sql`, `)}
@@ -74,7 +74,7 @@ describe('passcode query', () => {
   });
 
   it('findUnconsumedPasscodeByIdentifierAndType', async () => {
-    const type = VerificationCodeType.Generic;
+    const type = TemplateType.Generic;
     const phone = '1234567890';
     const mockGenericPasscode = { ...mockPasscode, interactionJti: null, type, phone };
 
@@ -99,7 +99,7 @@ describe('passcode query', () => {
   });
 
   it('findUnconsumedPasscodesByIdentifierAndType', async () => {
-    const type = VerificationCodeType.Generic;
+    const type = TemplateType.Generic;
     const email = 'johndoe@example.com';
     const mockGenericPasscode = { ...mockPasscode, interactionJti: null, type, email };
 
diff --git a/packages/core/src/queries/passcode.ts b/packages/core/src/queries/passcode.ts
index 1c1ac7e78..557001fb0 100644
--- a/packages/core/src/queries/passcode.ts
+++ b/packages/core/src/queries/passcode.ts
@@ -1,4 +1,4 @@
-import type { VerificationCodeType } from '@logto/connector-kit';
+import type { TemplateType } from '@logto/connector-kit';
 import type { Passcode, RequestVerificationCodePayload } from '@logto/schemas';
 import { Passcodes } from '@logto/schemas';
 import { conditionalSql, convertToIdentifiers } from '@logto/shared';
@@ -11,10 +11,10 @@ import { DeletionError } from '#src/errors/SlonikError/index.js';
 const { table, fields } = convertToIdentifiers(Passcodes);
 
 type FindByIdentifierAndTypeProperties = {
-  type: VerificationCodeType;
+  type: TemplateType;
 } & RequestVerificationCodePayload;
 
-const buildSqlForFindByJtiAndType = (jti: string, type: VerificationCodeType) => sql`
+const buildSqlForFindByJtiAndType = (jti: string, type: TemplateType) => sql`
   select ${sql.join(Object.values(fields), sql`, `)}
   from ${table}
   where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${fields.consumed} = false
@@ -40,10 +40,10 @@ const buildSqlForFindByIdentifierAndType = ({
 `;
 
 export const createPasscodeQueries = (pool: CommonQueryMethods) => {
-  const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: VerificationCodeType) =>
+  const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: TemplateType) =>
     pool.maybeOne<Passcode>(buildSqlForFindByJtiAndType(jti, type));
 
-  const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: VerificationCodeType) =>
+  const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: TemplateType) =>
     pool.any<Passcode>(buildSqlForFindByJtiAndType(jti, type));
 
   const findUnconsumedPasscodeByIdentifierAndType = async (
diff --git a/packages/core/src/routes-me/verification-code.ts b/packages/core/src/routes-me/verification-code.ts
index 8c2b9833e..0b3d136dd 100644
--- a/packages/core/src/routes-me/verification-code.ts
+++ b/packages/core/src/routes-me/verification-code.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import { emailRegEx } from '@logto/core-kit';
 import { literal, object, string, union } from 'zod';
 
@@ -12,7 +12,7 @@ import type { AuthedMeRouter } from './types.js';
 export default function verificationCodeRoutes<T extends AuthedMeRouter>(
   ...[router, tenant]: RouterInitArgs<T>
 ) {
-  const codeType = VerificationCodeType.Generic;
+  const codeType = TemplateType.Generic;
   const {
     queries: {
       users: { findUserById },
diff --git a/packages/core/src/routes/connector/config-testing.test.ts b/packages/core/src/routes/connector/config-testing.test.ts
index 64b5fb4dd..c3dd68bc0 100644
--- a/packages/core/src/routes/connector/config-testing.test.ts
+++ b/packages/core/src/routes/connector/config-testing.test.ts
@@ -1,6 +1,6 @@
 import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
 import type router from '@logto/cloud/routes';
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import type { EmailConnector, SmsConnector } from '@logto/connector-kit';
 import { ConnectorType } from '@logto/schemas';
 import { pickDefault, createMockUtils } from '@logto/shared/esm';
@@ -82,7 +82,7 @@ describe('connector services route', () => {
       expect(sendMessage).toHaveBeenCalledWith(
         {
           to: '12345678901',
-          type: VerificationCodeType.Generic,
+          type: TemplateType.Generic,
           payload: {
             code: '000000',
           },
@@ -109,7 +109,7 @@ describe('connector services route', () => {
       expect(sendMessage).toHaveBeenCalledWith(
         {
           to: 'test@email.com',
-          type: VerificationCodeType.Generic,
+          type: TemplateType.Generic,
           payload: {
             code: '000000',
           },
diff --git a/packages/core/src/routes/connector/config-testing.ts b/packages/core/src/routes/connector/config-testing.ts
index 64f998a4a..6e18bdac6 100644
--- a/packages/core/src/routes/connector/config-testing.ts
+++ b/packages/core/src/routes/connector/config-testing.ts
@@ -5,7 +5,7 @@ import {
   type SmsConnector,
   type EmailConnector,
   demoConnectorIds,
-  VerificationCodeType,
+  TemplateType,
 } from '@logto/connector-kit';
 import { ServiceConnector } from '@logto/connector-kit';
 import { phoneRegEx, emailRegEx } from '@logto/core-kit';
@@ -81,7 +81,7 @@ export default function connectorConfigTestingRoutes<T extends AuthedRouter>(
       await sendMessage(
         {
           to: subject,
-          type: VerificationCodeType.Generic,
+          type: TemplateType.Generic,
           payload: {
             code: '000000',
           },
diff --git a/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts b/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts
index cfdf925e3..4776605e7 100644
--- a/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts
+++ b/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import { InteractionEvent } from '@logto/schemas';
 
 import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
@@ -15,27 +15,27 @@ const { sendVerificationCodeToIdentifier } = await import('./verification-code-v
 const sendVerificationCodeTestCase = [
   {
     payload: { email: 'email', event: InteractionEvent.SignIn },
-    createVerificationCodeParams: [VerificationCodeType.SignIn, { email: 'email' }],
+    createVerificationCodeParams: [TemplateType.SignIn, { email: 'email' }],
   },
   {
     payload: { email: 'email', event: InteractionEvent.Register },
-    createVerificationCodeParams: [VerificationCodeType.Register, { email: 'email' }],
+    createVerificationCodeParams: [TemplateType.Register, { email: 'email' }],
   },
   {
     payload: { email: 'email', event: InteractionEvent.ForgotPassword },
-    createVerificationCodeParams: [VerificationCodeType.ForgotPassword, { email: 'email' }],
+    createVerificationCodeParams: [TemplateType.ForgotPassword, { email: 'email' }],
   },
   {
     payload: { phone: 'phone', event: InteractionEvent.SignIn },
-    createVerificationCodeParams: [VerificationCodeType.SignIn, { phone: 'phone' }],
+    createVerificationCodeParams: [TemplateType.SignIn, { phone: 'phone' }],
   },
   {
     payload: { phone: 'phone', event: InteractionEvent.Register },
-    createVerificationCodeParams: [VerificationCodeType.Register, { phone: 'phone' }],
+    createVerificationCodeParams: [TemplateType.Register, { phone: 'phone' }],
   },
   {
     payload: { phone: 'phone', event: InteractionEvent.ForgotPassword },
-    createVerificationCodeParams: [VerificationCodeType.ForgotPassword, { phone: 'phone' }],
+    createVerificationCodeParams: [TemplateType.ForgotPassword, { phone: 'phone' }],
   },
 ];
 
diff --git a/packages/core/src/routes/interaction/utils/verification-code-validation.ts b/packages/core/src/routes/interaction/utils/verification-code-validation.ts
index c0bcc643c..38ea73fc3 100644
--- a/packages/core/src/routes/interaction/utils/verification-code-validation.ts
+++ b/packages/core/src/routes/interaction/utils/verification-code-validation.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import type {
   InteractionEvent,
   RequestVerificationCodePayload,
@@ -10,16 +10,16 @@ import type { LogContext } from '#src/middleware/koa-audit-log.js';
 
 /**
  * Refactor Needed:
- * This is a work around to map the latest interaction event type to old VerificationCodeType
+ * This is a work around to map the latest interaction event type to old TemplateType
  *  */
-const eventToVerificationCodeTypeMap: Record<InteractionEvent, VerificationCodeType> = {
-  SignIn: VerificationCodeType.SignIn,
-  Register: VerificationCodeType.Register,
-  ForgotPassword: VerificationCodeType.ForgotPassword,
+const eventToTemplateTypeMap: Record<InteractionEvent, TemplateType> = {
+  SignIn: TemplateType.SignIn,
+  Register: TemplateType.Register,
+  ForgotPassword: TemplateType.ForgotPassword,
 };
 
-const getVerificationCodeTypeByEvent = (event: InteractionEvent): VerificationCodeType =>
-  eventToVerificationCodeTypeMap[event];
+const getTemplateTypeByEvent = (event: InteractionEvent): TemplateType =>
+  eventToTemplateTypeMap[event];
 
 export const sendVerificationCodeToIdentifier = async (
   payload: RequestVerificationCodePayload & { event: InteractionEvent },
@@ -28,7 +28,7 @@ export const sendVerificationCodeToIdentifier = async (
   { createPasscode, sendPasscode }: PasscodeLibrary
 ) => {
   const { event, ...identifier } = payload;
-  const messageType = getVerificationCodeTypeByEvent(event);
+  const messageType = getTemplateTypeByEvent(event);
 
   const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Create`);
   log.append(identifier);
@@ -46,7 +46,7 @@ export const verifyIdentifierByVerificationCode = async (
   passcodeLibrary: PasscodeLibrary
 ) => {
   const { event, verificationCode, ...identifier } = payload;
-  const messageType = getVerificationCodeTypeByEvent(event);
+  const messageType = getTemplateTypeByEvent(event);
 
   const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Submit`);
   log.append(identifier);
diff --git a/packages/core/src/routes/organization/invitations.openapi.json b/packages/core/src/routes/organization/invitations.openapi.json
index 12000d2a4..891a765a8 100644
--- a/packages/core/src/routes/organization/invitations.openapi.json
+++ b/packages/core/src/routes/organization/invitations.openapi.json
@@ -16,6 +16,56 @@
             "description": "A list of organization invitations, each item also contains the organization roles to be assigned to the user when they accept the invitation, and the corresponding magic link data."
           }
         }
+      },
+      "post": {
+        "summary": "Create organization invitation",
+        "description": "Create an organization invitation and optionally send it via email. The tenant should have an email connector configured if you want to send the invitation via email at this point.",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "skipEmail",
+            "description": "If true, the invitation will not be sent via email; otherwise, the invitation will be sent via email when it is created. If the email is failed to send, the invitation will not be created.",
+            "required": false,
+            "schema": {
+              "default": false
+            }
+          }
+        ],
+        "requestBody": {
+          "description": "The organization invitation to create.",
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "properties": {
+                  "inviterId": {
+                    "description": "The ID of the user who is inviting the user to join the organization."
+                  },
+                  "invitee": {
+                    "description": "The email address of the user to invite to join the organization."
+                  },
+                  "organizationId": {
+                    "description": "The ID of the organization to invite the user to join."
+                  },
+                  "expiresAt": {
+                    "description": "The epoch time in milliseconds when the invitation expires."
+                  },
+                  "organizationRoleIds": {
+                    "description": "The IDs of the organization roles to assign to the user when they accept the invitation."
+                  }
+                }
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "The organization invitation was created successfully, and the corresponding magic link data."
+          },
+          "501": {
+            "description": "No email connector is configured for the tenant."
+          }
+        }
       }
     },
     "/api/organization-invitations/{id}": {
diff --git a/packages/core/src/routes/organization/invitations.ts b/packages/core/src/routes/organization/invitations.ts
index 70be05bfa..17d32d89e 100644
--- a/packages/core/src/routes/organization/invitations.ts
+++ b/packages/core/src/routes/organization/invitations.ts
@@ -1,9 +1,14 @@
 import { OrganizationInvitations } from '@logto/schemas';
+import { z } from 'zod';
 
+import koaGuard from '#src/middleware/koa-guard.js';
 import SchemaRouter from '#src/utils/SchemaRouter.js';
+import assertThat from '#src/utils/assert-that.js';
 
 import { type AuthedRouter, type RouterInitArgs } from '../types.js';
 
+import { errorHandler } from './utils.js';
+
 export default function organizationInvitationRoutes<T extends AuthedRouter>(
   ...[
     originalRouter,
@@ -11,15 +16,51 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
       queries: {
         organizations: { invitations },
       },
+      libraries: { organizationInvitations },
     },
   ]: RouterInitArgs<T>
 ) {
   const router = new SchemaRouter(OrganizationInvitations, invitations, {
+    errorHandler,
     disabled: {
       post: true,
       patchById: true,
     },
   });
 
+  router.post(
+    '/',
+    koaGuard({
+      query: z.object({
+        skipEmail: z.boolean().optional(),
+      }),
+      body: OrganizationInvitations.createGuard
+        .pick({
+          inviterId: true,
+          invitee: true,
+          organizationId: true,
+          expiresAt: true,
+        })
+        .extend({
+          invitee: z.string().email(),
+          organizationRoleIds: z.string().array().optional(),
+        }),
+      response: OrganizationInvitations.guard,
+      status: [201],
+    }),
+    async (ctx) => {
+      const { query, body } = ctx.guard;
+
+      assertThat(
+        body.expiresAt > Date.now(),
+        // TODO: Throw `RequestError` instead.
+        new Error('The value of `expiresAt` must be in the future.')
+      );
+
+      ctx.body = await organizationInvitations.insert(body, query.skipEmail);
+      ctx.body = 201;
+    }
+  );
+
   originalRouter.use(router.routes());
 }
diff --git a/packages/core/src/routes/verification-code.test.ts b/packages/core/src/routes/verification-code.test.ts
index 0e9f4e517..91aa14ffc 100644
--- a/packages/core/src/routes/verification-code.test.ts
+++ b/packages/core/src/routes/verification-code.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import { createMockUtils, pickDefault } from '@logto/shared/esm';
 
 import { MockTenant } from '#src/test-utils/tenant.js';
@@ -30,7 +30,7 @@ describe('Generic verification code flow triggered by management API', () => {
     authedRoutes: verificationCodeRoutes,
     tenantContext,
   });
-  const type = VerificationCodeType.Generic;
+  const type = TemplateType.Generic;
 
   afterEach(() => {
     jest.clearAllMocks();
diff --git a/packages/core/src/routes/verification-code.ts b/packages/core/src/routes/verification-code.ts
index 01bfd8bc0..538f5645f 100644
--- a/packages/core/src/routes/verification-code.ts
+++ b/packages/core/src/routes/verification-code.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import {
   requestVerificationCodePayloadGuard,
   verifyVerificationCodePayloadGuard,
@@ -8,7 +8,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
 
 import type { AuthedRouter, RouterInitArgs } from './types.js';
 
-const codeType = VerificationCodeType.Generic;
+const codeType = TemplateType.Generic;
 
 export default function verificationCodeRoutes<T extends AuthedRouter>(
   ...[router, { libraries }]: RouterInitArgs<T>
diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts
index c08c9038e..7030b97e4 100644
--- a/packages/core/src/tenants/Libraries.ts
+++ b/packages/core/src/tenants/Libraries.ts
@@ -3,6 +3,7 @@ import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'
 import type { ConnectorLibrary } from '#src/libraries/connector.js';
 import { createDomainLibrary } from '#src/libraries/domain.js';
 import { createHookLibrary } from '#src/libraries/hook/index.js';
+import { OrganizationInvitationLibrary } from '#src/libraries/organization-invitation.js';
 import { createPasscodeLibrary } from '#src/libraries/passcode.js';
 import { createPhraseLibrary } from '#src/libraries/phrase.js';
 import { createProtectedAppLibrary } from '#src/libraries/protected-app.js';
@@ -34,6 +35,12 @@ export default class Libraries {
     this.cloudConnection
   );
 
+  organizationInvitations = new OrganizationInvitationLibrary(
+    this.tenantId,
+    this.queries,
+    this.connectors
+  );
+
   constructor(
     public readonly tenantId: string,
     private readonly queries: Queries,
diff --git a/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts b/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts
index 41ddbb6ca..8348dfcc4 100644
--- a/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts
+++ b/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts
@@ -10,7 +10,7 @@ import { generateEmail, generatePhone } from '#src/utils.js';
  * cannot be covered within the auth flow.
  */
 describe('POST /interaction/verification/verification-code', () => {
-  it('Should fail to send email verification code if related connector is not found', async () => {
+  it('should fail to send email verification code if related connector is not found', async () => {
     const client = await initClient();
 
     await client.successSend(putInteraction, {
@@ -23,12 +23,12 @@ describe('POST /interaction/verification/verification-code', () => {
       }),
       {
         code: 'connector.not_found',
-        statusCode: 400,
+        statusCode: 501,
       }
     );
   });
 
-  it('Should fail to send phone verification code if related connector is not found', async () => {
+  it('should fail to send phone verification code if related connector is not found', async () => {
     const client = await initClient();
 
     await client.successSend(putInteraction, {
@@ -41,7 +41,7 @@ describe('POST /interaction/verification/verification-code', () => {
       }),
       {
         code: 'connector.not_found',
-        statusCode: 400,
+        statusCode: 501,
       }
     );
   });
diff --git a/packages/integration-tests/src/tests/api/swagger-check.test.ts b/packages/integration-tests/src/tests/api/swagger-check.test.ts
index c4d857934..fadd7a8ca 100644
--- a/packages/integration-tests/src/tests/api/swagger-check.test.ts
+++ b/packages/integration-tests/src/tests/api/swagger-check.test.ts
@@ -13,16 +13,11 @@ describe('Swagger check', () => {
     expect(response.headers['content-type']).toContain('application/json');
 
     // Use multiple validators to be more confident
-    expect(async () => {
-      const object: unknown = JSON.parse(response.body);
+    const object: unknown = JSON.parse(response.body);
 
-      const validator = new OpenApiSchemaValidator({ version: 3 });
-      const result = validator.validate(object as OpenAPI.Document);
-      expect(result.errors).toEqual([]);
-
-      await expect(
-        SwaggerParser.default.validate(object as OpenAPI.Document)
-      ).resolves.not.toThrow();
-    }).not.toThrow();
+    const validator = new OpenApiSchemaValidator({ version: 3 });
+    const result = validator.validate(object as OpenAPI.Document);
+    expect(result.errors).toEqual([]);
+    await expect(SwaggerParser.default.validate(object as OpenAPI.Document)).resolves.not.toThrow();
   });
 });
diff --git a/packages/integration-tests/src/tests/api/verification-code.test.ts b/packages/integration-tests/src/tests/api/verification-code.test.ts
index 9a18d3a46..3e47f13d3 100644
--- a/packages/integration-tests/src/tests/api/verification-code.test.ts
+++ b/packages/integration-tests/src/tests/api/verification-code.test.ts
@@ -1,4 +1,4 @@
-import { VerificationCodeType } from '@logto/connector-kit';
+import { TemplateType } from '@logto/connector-kit';
 import { ConnectorType, type RequestVerificationCodePayload } from '@logto/schemas';
 
 import { requestVerificationCode, verifyVerificationCode } from '#src/api/verification-code.js';
@@ -36,7 +36,7 @@ describe('Generic verification code through management API', () => {
 
     const { code, type, address } = await readVerificationCode();
 
-    expect(type).toBe(VerificationCodeType.Generic);
+    expect(type).toBe(TemplateType.Generic);
     expect(address).toBe(mockEmail);
     expect(code).not.toBeNull();
   });
@@ -48,7 +48,7 @@ describe('Generic verification code through management API', () => {
 
     const { code, type, phone } = await readVerificationCode();
 
-    expect(type).toBe(VerificationCodeType.Generic);
+    expect(type).toBe(TemplateType.Generic);
     expect(phone).toBe(mockPhone);
     expect(code).not.toBeNull();
   });
@@ -67,7 +67,7 @@ describe('Generic verification code through management API', () => {
     await clearConnectorsByTypes([ConnectorType.Email]);
     await expectRejects(requestVerificationCode({ email: emailForTestSendCode }), {
       code: 'connector.not_found',
-      statusCode: 400,
+      statusCode: 501,
     });
 
     await expect(
@@ -91,7 +91,7 @@ describe('Generic verification code through management API', () => {
     await clearConnectorsByTypes([ConnectorType.Sms]);
     await expectRejects(requestVerificationCode({ phone: phoneForTestSendCode }), {
       code: 'connector.not_found',
-      statusCode: 400,
+      statusCode: 501,
     });
 
     await expect(
diff --git a/packages/toolkit/connector-kit/src/index.test.ts b/packages/toolkit/connector-kit/src/index.test.ts
index 3a57b6521..5016b4007 100644
--- a/packages/toolkit/connector-kit/src/index.test.ts
+++ b/packages/toolkit/connector-kit/src/index.test.ts
@@ -1,50 +1,83 @@
 import { z } from 'zod';
 
-import { parseJson, parseJsonObject, validateConfig } from './index.js';
+import {
+  parseJson,
+  parseJsonObject,
+  replaceSendMessageHandlebars,
+  validateConfig,
+} from './index.js';
 
-describe('connector-kit', () => {
-  describe('validateConfig', () => {
-    it('valid config', () => {
-      const testingTypeGuard = z.unknown();
-      type TestingType = z.infer<typeof testingTypeGuard>;
-      const testingConfig = { foo: 'foo', bar: 1, baz: true };
-      expect(() => {
-        validateConfig(testingConfig, testingTypeGuard);
-      }).not.toThrow();
-    });
-
-    it('invalid config', () => {
-      const testingTypeGuard = z.record(z.string());
-      type TestingType = z.infer<typeof testingTypeGuard>;
-      const testingConfig = { foo: 'foo', bar: 1 };
-      expect(() => {
-        validateConfig(testingConfig, testingTypeGuard);
-      }).toThrow();
-    });
+describe('validateConfig', () => {
+  it('valid config', () => {
+    const testingTypeGuard = z.unknown();
+    const testingConfig = { foo: 'foo', bar: 1, baz: true };
+    expect(() => {
+      validateConfig(testingConfig, testingTypeGuard);
+    }).not.toThrow();
   });
 
-  describe('parseJson', () => {
-    it('should return parsed result', () => {
-      const literalContent = 'foo';
-      expect(parseJson(JSON.stringify(literalContent))).toEqual(literalContent);
-
-      const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] };
-      expect(parseJson(JSON.stringify(objectContent))).toEqual(objectContent);
-    });
-
-    it('throw error when parsing invalid Json string', () => {
-      expect(() => parseJson('[1,2,3,"4",]')).toThrow();
-    });
-  });
-
-  describe('parseJsonObject', () => {
-    it('should return parsed object', () => {
-      const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] };
-      expect(parseJsonObject(JSON.stringify(objectContent))).toEqual(objectContent);
-    });
-
-    it('throw error when parsing non-object result', () => {
-      expect(() => parseJsonObject(JSON.stringify('foo'))).toThrow();
-    });
+  it('invalid config', () => {
+    const testingTypeGuard = z.record(z.string());
+    const testingConfig = { foo: 'foo', bar: 1 };
+    expect(() => {
+      validateConfig(testingConfig, testingTypeGuard);
+    }).toThrow();
+  });
+});
+
+describe('parseJson', () => {
+  it('should return parsed result', () => {
+    const literalContent = 'foo';
+    expect(parseJson(JSON.stringify(literalContent))).toEqual(literalContent);
+
+    const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] };
+    expect(parseJson(JSON.stringify(objectContent))).toEqual(objectContent);
+  });
+
+  it('throw error when parsing invalid Json string', () => {
+    expect(() => parseJson('[1,2,3,"4",]')).toThrow();
+  });
+});
+
+describe('parseJsonObject', () => {
+  it('should return parsed object', () => {
+    const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] };
+    expect(parseJsonObject(JSON.stringify(objectContent))).toEqual(objectContent);
+  });
+
+  it('throw error when parsing non-object result', () => {
+    expect(() => parseJsonObject(JSON.stringify('foo'))).toThrow();
+  });
+});
+
+describe('replaceSendMessageHandlebars', () => {
+  it('should replace handlebars with payload', () => {
+    const template = 'Your verification code is {{code}}';
+    const payload = { code: '123456' };
+    expect(replaceSendMessageHandlebars(template, payload)).toEqual(
+      'Your verification code is 123456'
+    );
+  });
+
+  it('should replace handlebars with empty string if payload does not contain the key', () => {
+    const template = 'Your verification code is {{code}}';
+    const payload = {};
+    expect(replaceSendMessageHandlebars(template, payload)).toEqual('Your verification code is ');
+  });
+
+  it('should ignore handlebars that are not in the predefined list for both template and payload', () => {
+    const template = 'Your verification code is {{code}} and {{foo}}';
+    const payload = { code: '123456', foo: 'bar' };
+    expect(replaceSendMessageHandlebars(template, payload)).toEqual(
+      'Your verification code is 123456 and {{foo}}'
+    );
+  });
+
+  it('should replace handlebars that have extra spaces with payload', () => {
+    const template = 'Your verification code is {{     code }}';
+    const payload = { code: '123456' };
+    expect(replaceSendMessageHandlebars(template, payload)).toEqual(
+      'Your verification code is 123456'
+    );
   });
 });
diff --git a/packages/toolkit/connector-kit/src/index.ts b/packages/toolkit/connector-kit/src/index.ts
index 09c24d49a..b95ac72c3 100644
--- a/packages/toolkit/connector-kit/src/index.ts
+++ b/packages/toolkit/connector-kit/src/index.ts
@@ -1,6 +1,11 @@
 import type { ZodType, ZodTypeDef } from 'zod';
 
-import { ConnectorError, ConnectorErrorCodes } from './types/index.js';
+import {
+  ConnectorError,
+  ConnectorErrorCodes,
+  sendMessagePayloadKeys,
+  type SendMessagePayload,
+} from './types/index.js';
 
 export * from './types/index.js';
 
@@ -38,3 +43,35 @@ export const parseJsonObject = (...args: Parameters<typeof parseJson>) => {
 };
 
 export const mockSmsVerificationCodeFileName = 'logto_mock_verification_code_record.txt';
+
+/**
+ * Replace all handlebars that match the keys in {@link SendMessagePayload} with the payload
+ * values. If the payload does not contain the key, the handlebar will be replaced with an empty
+ * string.
+ *
+ * @param template The template to replace the handlebars with.
+ * @param payload The payload to replace the handlebars with.
+ * @returns The replaced template.
+ *
+ * @example
+ * ```ts
+ * replaceSendMessageKeysWithPayload('Your verification code is {{code}}', { code: '123456' });
+ * // 'Your verification code is 123456'
+ * ```
+ *
+ * @example
+ * ```ts
+ * replaceSendMessageKeysWithPayload('Your verification code is {{code}}', {});
+ * // 'Your verification code is '
+ * ```
+ */
+export const replaceSendMessageHandlebars = (
+  template: string,
+  payload: SendMessagePayload
+): string => {
+  return sendMessagePayloadKeys.reduce(
+    (accumulator, key) =>
+      accumulator.replaceAll(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), payload[key] ?? ''),
+    template
+  );
+};
diff --git a/packages/toolkit/connector-kit/src/types/error.ts b/packages/toolkit/connector-kit/src/types/error.ts
index 0e976502b..64258eeed 100644
--- a/packages/toolkit/connector-kit/src/types/error.ts
+++ b/packages/toolkit/connector-kit/src/types/error.ts
@@ -10,7 +10,11 @@ export enum ConnectorErrorCodes {
   InvalidResponse = 'invalid_response',
   /** The template is not found for the given type. */
   TemplateNotFound = 'template_not_found',
-  /** The template type is not supported by the connector. */
+  /**
+   * The template type is not supported by the connector.
+   *
+   * @deprecated Connector should be able to handle dynamic template type.
+   */
   TemplateNotSupported = 'template_not_supported',
   RateLimitExceeded = 'rate_limit_exceeded',
   NotImplemented = 'not_implemented',
diff --git a/packages/toolkit/connector-kit/src/types/passwordless.ts b/packages/toolkit/connector-kit/src/types/passwordless.ts
index 429312035..688a60a03 100644
--- a/packages/toolkit/connector-kit/src/types/passwordless.ts
+++ b/packages/toolkit/connector-kit/src/types/passwordless.ts
@@ -4,6 +4,7 @@ import { z } from 'zod';
 
 import { type BaseConnector, type ConnectorType } from './foundation.js';
 
+/** @deprecated Use {@link TemplateType} instead. */
 export enum VerificationCodeType {
   SignIn = 'SignIn',
   Register = 'Register',
@@ -13,18 +14,45 @@ export enum VerificationCodeType {
   Test = 'Test',
 }
 
+/** @deprecated Use {@link templateTypeGuard} instead. */
 export const verificationCodeTypeGuard = z.nativeEnum(VerificationCodeType);
 
+export enum TemplateType {
+  /** The template for sending verification code when user is signing in. */
+  SignIn = 'SignIn',
+  /** The template for sending verification code when user is registering. */
+  Register = 'Register',
+  /** The template for sending verification code when user is resetting password. */
+  ForgotPassword = 'ForgotPassword',
+  /** The template for sending organization invitation. */
+  OrganizationInvitation = 'OrganizationInvitation',
+  /** The template for generic usage. */
+  Generic = 'Generic',
+}
+
+export const templateTypeGuard = z.nativeEnum(TemplateType);
+
 export type SendMessagePayload = {
   /**
-   * The dynamic verification code to send.
+   * The dynamic verification code to send. It will replace the `{{code}}` handlebars in the
+   * template.
    * @example '123456'
    */
-  code: string;
+  code?: string;
+  /**
+   * The dynamic link to send. It will replace the `{{link}}` handlebars in the template.
+   * @example 'https://example.com'
+   */
+  link?: string;
 };
 
+export const sendMessagePayloadKeys = ['code', 'link'] as const satisfies Array<
+  keyof SendMessagePayload
+>;
+
 export const sendMessagePayloadGuard = z.object({
-  code: z.string(),
+  code: z.string().optional(),
+  link: z.string().optional(),
 }) satisfies z.ZodType<SendMessagePayload>;
 
 export const urlRegEx =
@@ -34,13 +62,10 @@ export const emailServiceBrandingGuard = z
   .object({
     senderName: z
       .string()
-      .refine((address) => !urlRegEx.test(address), 'DO NOT include URL in the sender name!'),
+      .refine((address) => !urlRegEx.test(address), 'URL is not allowed in sender name.'),
     companyInformation: z
       .string()
-      .refine(
-        (address) => !urlRegEx.test(address),
-        'DO NOT include URL in the company information!'
-      ),
+      .refine((address) => !urlRegEx.test(address), 'URL is not allowed in company information.'),
     appLogo: z.string().url(),
   })
   .partial();
@@ -49,13 +74,13 @@ export type EmailServiceBranding = z.infer<typeof emailServiceBrandingGuard>;
 
 export type SendMessageData = {
   to: string;
-  type: VerificationCodeType;
+  type: TemplateType | VerificationCodeType;
   payload: SendMessagePayload;
 };
 
 export const sendMessageDataGuard = z.object({
   to: z.string(),
-  type: verificationCodeTypeGuard,
+  type: templateTypeGuard.or(verificationCodeTypeGuard),
   payload: sendMessagePayloadGuard,
 }) satisfies z.ZodType<SendMessageData>;