@lobehub/lobehub 2.0.0-next.143 → 2.0.0-next.145

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ alwaysApply: true
4
4
 
5
5
  ## Project Description
6
6
 
7
- You are developing an open-source, modern-design AI chat framework: lobehub(previous lobe-chat).
7
+ You are developing an open-source, modern-design AI Agent Workspace: LobeHub(previous LobeChat).
8
8
 
9
9
  Supported platforms:
10
10
 
package/AGENTS.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  This document serves as a comprehensive guide for all team members when developing LobeChat.
4
4
 
5
+ ## Project Description
6
+
7
+ You are developing an open-source, modern-design AI Agent Workspace: LobeHub(previous LobeChat).
8
+
5
9
  ## Tech Stack
6
10
 
7
11
  Built with modern technologies:
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.145](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.144...v2.0.0-next.145)
6
+
7
+ <sup>Released on **2025-12-02**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **misc**: Email provider support resend.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **misc**: Email provider support resend, closes [#10557](https://github.com/lobehub/lobe-chat/issues/10557) ([7449b29](https://github.com/lobehub/lobe-chat/commit/7449b29))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ## [Version 2.0.0-next.144](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.143...v2.0.0-next.144)
31
+
32
+ <sup>Released on **2025-12-02**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: User email unique migration error.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: User email unique migration error, closes [#10548](https://github.com/lobehub/lobe-chat/issues/10548) ([ca2a1a2](https://github.com/lobehub/lobe-chat/commit/ca2a1a2))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ## [Version 2.0.0-next.143](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.142...v2.0.0-next.143)
6
56
 
7
57
  <sup>Released on **2025-12-02**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "features": [
5
+ "Email provider support resend."
6
+ ]
7
+ },
8
+ "date": "2025-12-02",
9
+ "version": "2.0.0-next.145"
10
+ },
11
+ {
12
+ "children": {
13
+ "fixes": [
14
+ "User email unique migration error."
15
+ ]
16
+ },
17
+ "date": "2025-12-02",
18
+ "version": "2.0.0-next.144"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "features": [
@@ -89,16 +89,27 @@ When configuring OAuth providers, use the following callback URL format:
89
89
 
90
90
  ### Email Service Configuration
91
91
 
92
- If you want to enable email verification or password reset features, you need to configure SMTP settings:
93
-
94
- | Environment Variable | Type | Description |
95
- | ------------------------------------- | -------- | ----------------------------------------------------------------- |
96
- | `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification before users can sign in |
97
- | `SMTP_HOST` | Required | SMTP server hostname (e.g., `smtp.gmail.com`) |
98
- | `SMTP_PORT` | Required | SMTP server port (usually `587` for TLS, `465` for SSL) |
99
- | `SMTP_SECURE` | Optional | Set to `true` for SSL (port 465), `false` for TLS (port 587) |
100
- | `SMTP_USER` | Required | SMTP authentication username |
101
- | `SMTP_PASS` | Required | SMTP authentication password |
92
+ Used by email verification, password reset, and magic-link delivery. Choose a provider, then fill the matching variables:
93
+
94
+ | Environment Variable | Type | Description |
95
+ | ------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
96
+ | `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification before users can sign in |
97
+ | `EMAIL_SERVICE_PROVIDER` | Optional | Email provider selector: `nodemailer` (default, SMTP) or `resend` |
98
+ | `SMTP_HOST` | Required | SMTP server hostname (e.g., `smtp.gmail.com`). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
99
+ | `SMTP_PORT` | Required | SMTP server port (usually `587` for TLS, `465` for SSL). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
100
+ | `SMTP_SECURE` | Optional | `true` for SSL (port 465), `false` for TLS (port 587). Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
101
+ | `SMTP_USER` | Required | SMTP auth username. Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
102
+ | `SMTP_PASS` | Required | SMTP auth password. Used when `EMAIL_SERVICE_PROVIDER=nodemailer` |
103
+ | `RESEND_API_KEY` | Required | Resend API key. Required when `EMAIL_SERVICE_PROVIDER=resend` |
104
+ | `RESEND_FROM` | Recommended | Default sender address (e.g., `noreply@your-verified-domain.com`). Must be a domain verified in Resend. Used when `EMAIL_SERVICE_PROVIDER=resend` |
105
+
106
+ ### Magic Link (Passwordless) Login
107
+
108
+ Enable BetterAuth magic-link login (depends on a working email provider above):
109
+
110
+ | Environment Variable | Type | Description |
111
+ | ------------------------------- | -------- | -------------------------------------------------- |
112
+ | `NEXT_PUBLIC_ENABLE_MAGIC_LINK` | Optional | Set to `1` to enable passwordless magic-link login |
102
113
 
103
114
  <Callout type={'tip'}>
104
115
  For detailed provider configuration, refer to the [Next Auth provider documentation](/docs/self-hosting/advanced/auth/next-auth) as most configurations are compatible, or visit the official [Better Auth documentation](https://www.better-auth.com/docs/introduction).
@@ -87,16 +87,27 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
87
87
 
88
88
  ### 邮件服务配置
89
89
 
90
- 如果需要启用邮箱验证或密码重置功能,需要配置 SMTP 设置:
91
-
92
- | 环境变量 | 类型 | 描述 |
93
- | ------------------------------------- | -- | ---------------------------------------------- |
94
- | `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱 |
95
- | `SMTP_HOST` | 必选 | SMTP 服务器主机名(例如 `smtp.gmail.com`) |
96
- | `SMTP_PORT` | 必选 | SMTP 服务器端口(TLS 通常为 `587`,SSL 为 `465`) |
97
- | `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465),TLS 设置为 `false`(端口 587) |
98
- | `SMTP_USER` | 必选 | SMTP 认证用户名 |
99
- | `SMTP_PASS` | 必选 | SMTP 认证密码 |
90
+ 用于邮箱验证、密码重置和魔法链接发送。先选择邮件服务,再填对应变量:
91
+
92
+ | 环境变量 | 类型 | 描述 |
93
+ | ------------------------------------- | -- | ----------------------------------------------------------------------------------------- |
94
+ | `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱 |
95
+ | `EMAIL_SERVICE_PROVIDER` | 可选 | 邮件服务选择:`nodemailer`(默认,SMTP)或 `resend` |
96
+ | `SMTP_HOST` | 必选 | SMTP 服务器主机名(如 `smtp.gmail.com`),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
97
+ | `SMTP_PORT` | 必选 | SMTP 服务器端口(TLS 通常为 `587`,SSL `465`),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
98
+ | `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465),TLS 设置为 `false`(端口 587),仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
99
+ | `SMTP_USER` | 必选 | SMTP 认证用户名,仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
100
+ | `SMTP_PASS` | 必选 | SMTP 认证密码,仅在 `EMAIL_SERVICE_PROVIDER=nodemailer` 时需要 |
101
+ | `RESEND_API_KEY` | 必选 | Resend API Key,`EMAIL_SERVICE_PROVIDER=resend` 时必填 |
102
+ | `RESEND_FROM` | 推荐 | 默认发件人地址(如 `noreply@已验证域名`),需为 Resend 已验证域名下的邮箱,`EMAIL_SERVICE_PROVIDER=resend` 时使用 |
103
+
104
+ ### 魔法链接(免密)登录
105
+
106
+ 启用 BetterAuth 魔法链接登录(依赖上方已配置好的邮件服务):
107
+
108
+ | 环境变量 | 类型 | 描述 |
109
+ | ------------------------------- | -- | ----------------- |
110
+ | `NEXT_PUBLIC_ENABLE_MAGIC_LINK` | 可选 | 设置为 `1` 以启用魔法链接登录 |
100
111
 
101
112
  <Callout type={'tip'}>
102
113
  详细的提供商配置可参考 [Next Auth 提供商文档](/zh/docs/self-hosting/advanced/auth/next-auth)(大部分配置兼容),或访问官方 [Better Auth 文档](https://www.better-auth.com/docs/introduction)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.143",
3
+ "version": "2.0.0-next.145",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -285,6 +285,7 @@
285
285
  "remark": "^15.0.1",
286
286
  "remark-gfm": "^4.0.1",
287
287
  "remark-html": "^16.0.1",
288
+ "resend": "^6.5.2",
288
289
  "resolve-accept-language": "^3.1.15",
289
290
  "rtl-detect": "^1.1.2",
290
291
  "semver": "^7.7.3",
@@ -35,6 +35,8 @@ CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verifications" USIN
35
35
  DO $$
36
36
  BEGIN
37
37
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_email_unique') THEN
38
+ -- Normalize empty emails so the unique constraint can be created safely
39
+ UPDATE "users" SET "email" = NULL WHERE "email" = '';
38
40
  ALTER TABLE "users" ADD CONSTRAINT "users_email_unique" UNIQUE ("email");
39
41
  END IF;
40
42
  END $$;
@@ -884,7 +884,7 @@
884
884
  "\nCREATE INDEX IF NOT EXISTS \"account_userId_idx\" ON \"accounts\" USING btree (\"user_id\");\n",
885
885
  "\nCREATE INDEX IF NOT EXISTS \"auth_session_userId_idx\" ON \"auth_sessions\" USING btree (\"user_id\");\n",
886
886
  "\nCREATE INDEX IF NOT EXISTS \"verification_identifier_idx\" ON \"verifications\" USING btree (\"identifier\");\n",
887
- "\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_email_unique') THEN\n ALTER TABLE \"users\" ADD CONSTRAINT \"users_email_unique\" UNIQUE (\"email\");\n END IF;\nEND $$;\n",
887
+ "\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_email_unique') THEN\n UPDATE \"users\" SET \"email\" = NULL WHERE \"email\" = '';\n ALTER TABLE \"users\" ADD CONSTRAINT \"users_email_unique\" UNIQUE (\"email\");\n END IF;\nEND $$;\n",
888
888
  "\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_phone_number_unique') THEN\n ALTER TABLE \"users\" ADD CONSTRAINT \"users_phone_number_unique\" UNIQUE (\"phone_number\");\n END IF;\nEND $$;\n"
889
889
  ],
890
890
  "bps": true,
@@ -149,9 +149,11 @@ export class UserModel {
149
149
  };
150
150
 
151
151
  updateUser = async (value: Partial<UserItem>) => {
152
+ const nextValue = UserModel.normalizeUniqueUserFields(value);
153
+
152
154
  return this.db
153
155
  .update(users)
154
- .set({ ...value, updatedAt: new Date() })
156
+ .set({ ...nextValue, updatedAt: new Date() })
155
157
  .where(eq(users.id, this.userId));
156
158
  };
157
159
 
@@ -193,6 +195,26 @@ export class UserModel {
193
195
  .where(eq(users.id, this.userId));
194
196
  };
195
197
 
198
+ /**
199
+ * Normalize unique user fields so empty strings become null, keeping unique constraints safe.
200
+ */
201
+ private static normalizeUniqueUserFields = <
202
+ T extends { email?: string | null; phone?: string | null },
203
+ >(
204
+ value: T,
205
+ ) => {
206
+ const normalizedEmail =
207
+ typeof value.email === 'string' && value.email.trim() === '' ? null : value.email;
208
+ const normalizedPhone =
209
+ typeof value.phone === 'string' && value.phone.trim() === '' ? null : value.phone;
210
+
211
+ return {
212
+ ...value,
213
+ ...(value.email !== undefined ? { email: normalizedEmail } : {}),
214
+ ...(value.phone !== undefined ? { phone: normalizedPhone } : {}),
215
+ };
216
+ };
217
+
196
218
  // Static method
197
219
  static makeSureUserExist = async (db: LobeChatDatabase, userId: string) => {
198
220
  await db.insert(users).values({ id: userId }).onConflictDoNothing();
@@ -205,10 +227,8 @@ export class UserModel {
205
227
  if (!!user) return { duplicate: true };
206
228
  }
207
229
 
208
- const [user] = await db
209
- .insert(users)
210
- .values({ ...params })
211
- .returning();
230
+ const normalizedParams = this.normalizeUniqueUserFields(params);
231
+ const [user] = await db.insert(users).values(normalizedParams).returning();
212
232
 
213
233
  return { duplicate: false, user };
214
234
  };
@@ -24,7 +24,33 @@ DATABASE_DRIVER=node
24
24
  if you have any other question, please open issue here: https://github.com/lobehub/lobe-chat/issues
25
25
  `;
26
26
 
27
+ const DUPLICATE_EMAIL_HINT = `------------------------------------------------------------------------------------------
28
+ ⚠️ Database migration failed due to duplicate email addresses in the users table.
29
+
30
+ The database schema requires each email to be unique, but multiple users currently share the same email value.
31
+
32
+ Recommended solutions (choose one and rerun the migration):
33
+
34
+ 1) Update duplicate emails to make them unique: change the conflicting email addresses to another unique email address or just change them email to NULL
35
+ 2) Remove duplicate user records (dangerously, only if safe to delete)
36
+
37
+ ⚠️ IMPORTANT: Always backup your database before making any changes!
38
+
39
+ To find duplicate emails, run this query:
40
+
41
+ \`\`\`sql
42
+ SELECT email, COUNT(*) as count
43
+ FROM users
44
+ WHERE email IS NOT NULL
45
+ GROUP BY email
46
+ HAVING COUNT(*) > 1;
47
+ \`\`\`
48
+
49
+ If you need further assistance, please open an issue: https://github.com/lobehub/lobe-chat/issues
50
+ `;
51
+
27
52
  module.exports = {
28
53
  DB_FAIL_INIT_HINT,
54
+ DUPLICATE_EMAIL_HINT,
29
55
  PGVECTOR_HINT,
30
56
  };
@@ -4,7 +4,7 @@ import { migrate as nodeMigrate } from 'drizzle-orm/node-postgres/migrator';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  // @ts-ignore tsgo handle esm import cjs and compatibility issues
7
- import { DB_FAIL_INIT_HINT, PGVECTOR_HINT } from './errorHint';
7
+ import { DB_FAIL_INIT_HINT, DUPLICATE_EMAIL_HINT, PGVECTOR_HINT } from './errorHint';
8
8
 
9
9
  // Read the `.env` file if it exists, or a file specified by the
10
10
  // dotenv_config_path parameter that's passed to Node.js
@@ -39,8 +39,12 @@ if (!isDesktop && connectionString) {
39
39
 
40
40
  const errMsg = err.message as string;
41
41
 
42
+ const constraint = (err as { constraint?: string })?.constraint;
43
+
42
44
  if (errMsg.includes('extension "vector" is not available')) {
43
45
  console.info(PGVECTOR_HINT);
46
+ } else if (constraint === 'users_email_unique' || errMsg.includes('users_email_unique')) {
47
+ console.info(DUPLICATE_EMAIL_HINT);
44
48
  } else if (errMsg.includes(`Cannot read properties of undefined (reading 'migrate')`)) {
45
49
  console.info(DB_FAIL_INIT_HINT);
46
50
  }
package/src/envs/email.ts CHANGED
@@ -6,6 +6,9 @@ declare global {
6
6
  // eslint-disable-next-line @typescript-eslint/no-namespace
7
7
  namespace NodeJS {
8
8
  interface ProcessEnv {
9
+ EMAIL_SERVICE_PROVIDER?: string;
10
+ RESEND_API_KEY?: string;
11
+ RESEND_FROM?: string;
9
12
  SMTP_HOST?: string;
10
13
  SMTP_PASS?: string;
11
14
  SMTP_PORT?: string;
@@ -18,6 +21,9 @@ declare global {
18
21
  export const getEmailConfig = () => {
19
22
  return createEnv({
20
23
  server: {
24
+ EMAIL_SERVICE_PROVIDER: z.enum(['nodemailer', 'resend']).optional(),
25
+ RESEND_API_KEY: z.string().optional(),
26
+ RESEND_FROM: z.string().optional(),
21
27
  SMTP_HOST: z.string().optional(),
22
28
  SMTP_PORT: z.coerce.number().optional(),
23
29
  SMTP_SECURE: z.boolean().optional(),
@@ -30,6 +36,11 @@ export const getEmailConfig = () => {
30
36
  SMTP_SECURE: process.env.SMTP_SECURE === 'true',
31
37
  SMTP_USER: process.env.SMTP_USER,
32
38
  SMTP_PASS: process.env.SMTP_PASS,
39
+ EMAIL_SERVICE_PROVIDER: process.env.EMAIL_SERVICE_PROVIDER
40
+ ? process.env.EMAIL_SERVICE_PROVIDER.toLowerCase()
41
+ : undefined,
42
+ RESEND_API_KEY: process.env.RESEND_API_KEY,
43
+ RESEND_FROM: process.env.RESEND_FROM,
33
44
  },
34
45
  });
35
46
  };
@@ -18,7 +18,7 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
18
18
  <head>
19
19
  <meta charset="utf-8">
20
20
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
21
- <title>Sign in to LobeChat</title>
21
+ <title>Sign in to LobeHub</title>
22
22
  </head>
23
23
  <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f5; color: #1a1a1a;">
24
24
  <!-- Container -->
@@ -28,7 +28,7 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
28
28
  <div style="text-align: center; margin-bottom: 32px;">
29
29
  <div style="display: inline-flex; align-items: center; justify-content: center; background-color: #ffffff; border-radius: 12px; padding: 8px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
30
30
  <span style="font-size: 24px; line-height: 1; margin-right: 10px;">🤯</span>
31
- <span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeChat</span>
31
+ <span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeHub</span>
32
32
  </div>
33
33
  </div>
34
34
 
@@ -38,7 +38,7 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
38
38
  <!-- Header -->
39
39
  <div style="text-align: center; margin-bottom: 32px;">
40
40
  <h1 style="color: #111827; font-size: 24px; font-weight: 700; margin: 0 0 12px 0; letter-spacing: -0.5px;">
41
- Sign in to LobeChat
41
+ Sign in to LobeHub
42
42
  </h1>
43
43
  <p style="color: #6b7280; font-size: 16px; margin: 0; line-height: 1.5;">
44
44
  Click the link below to sign in to your account.
@@ -85,14 +85,14 @@ export const getMagicLinkEmailTemplate = (params: { expiresInSeconds: number; ur
85
85
  <!-- Footer -->
86
86
  <div style="text-align: center; margin-top: 32px;">
87
87
  <p style="color: #a1a1aa; font-size: 13px; margin: 0;">
88
- © ${new Date().getFullYear()} LobeChat. All rights reserved.
88
+ © ${new Date().getFullYear()} LobeHub. All rights reserved.
89
89
  </p>
90
90
  </div>
91
91
  </div>
92
92
  </body>
93
93
  </html>
94
94
  `,
95
- subject: 'Your LobeChat sign-in link',
95
+ subject: 'Your LobeHub sign-in link',
96
96
  text: `Use this link to sign in: ${url}\n\nThis link expires in ${expirationText}.`,
97
97
  };
98
98
  };
@@ -22,7 +22,7 @@ export const getResetPasswordEmailTemplate = (params: { url: string }) => {
22
22
  <div style="text-align: center; margin-bottom: 32px;">
23
23
  <div style="display: inline-flex; align-items: center; justify-content: center; background-color: #ffffff; border-radius: 12px; padding: 8px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
24
24
  <span style="font-size: 24px; line-height: 1; margin-right: 10px;">🤯</span>
25
- <span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeChat</span>
25
+ <span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeHub</span>
26
26
  </div>
27
27
  </div>
28
28
 
@@ -42,7 +42,7 @@ export const getResetPasswordEmailTemplate = (params: { url: string }) => {
42
42
  <!-- Content -->
43
43
  <div style="color: #374151; font-size: 16px; line-height: 1.6;">
44
44
  <p style="margin: 0 0 24px 0; text-align: center;">
45
- You recently requested to reset your password for your LobeChat account. Click the button below to proceed.
45
+ You recently requested to reset your password for your LobeHub account. Click the button below to proceed.
46
46
  </p>
47
47
 
48
48
  <!-- Button -->
@@ -78,14 +78,14 @@ export const getResetPasswordEmailTemplate = (params: { url: string }) => {
78
78
  <!-- Footer -->
79
79
  <div style="text-align: center; margin-top: 32px;">
80
80
  <p style="color: #a1a1aa; font-size: 13px; margin: 0;">
81
- © ${new Date().getFullYear()} LobeChat. All rights reserved.
81
+ © ${new Date().getFullYear()} LobeHub. All rights reserved.
82
82
  </p>
83
83
  </div>
84
84
  </div>
85
85
  </body>
86
86
  </html>
87
87
  `,
88
- subject: 'Reset Your Password - LobeChat',
88
+ subject: 'Reset Your Password - LobeHub',
89
89
  text: `Reset your password by clicking this link: ${url}`,
90
90
  };
91
91
  };
@@ -33,7 +33,7 @@ export const getVerificationEmailTemplate = (params: {
33
33
  <div style="text-align: center; margin-bottom: 32px;">
34
34
  <div style="display: inline-flex; align-items: center; justify-content: center; background-color: #ffffff; border-radius: 12px; padding: 8px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
35
35
  <span style="font-size: 24px; line-height: 1; margin-right: 10px;">🤯</span>
36
- <span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeChat</span>
36
+ <span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeHub</span>
37
37
  </div>
38
38
  </div>
39
39
 
@@ -55,7 +55,7 @@ export const getVerificationEmailTemplate = (params: {
55
55
  ${userName ? `<p style="margin: 0 0 16px 0;">Hi <strong>${userName}</strong>,</p>` : ''}
56
56
 
57
57
  <p style="margin: 0 0 24px 0;">
58
- Thanks for creating an account with LobeChat. To access your account, please verify your email address by clicking the button below.
58
+ Thanks for creating an account with LobeHub. To access your account, please verify your email address by clicking the button below.
59
59
  </p>
60
60
 
61
61
  <!-- Button -->
@@ -95,14 +95,14 @@ export const getVerificationEmailTemplate = (params: {
95
95
  <!-- Footer -->
96
96
  <div style="text-align: center; margin-top: 32px;">
97
97
  <p style="color: #a1a1aa; font-size: 13px; margin: 0;">
98
- © 2025 LobeChat. All rights reserved.
98
+ © 2025 LobeHub. All rights reserved.
99
99
  </p>
100
100
  </div>
101
101
  </div>
102
102
  </body>
103
103
  </html>
104
104
  `,
105
- subject: 'Verify Your Email - LobeChat',
105
+ subject: 'Verify Your Email - LobeHub',
106
106
  text: `Please verify your email by clicking this link: ${url}\n\nThis link will expire in ${expirationText}.`,
107
107
  };
108
108
  };
@@ -86,6 +86,25 @@ SMTP_USER=your-username
86
86
  SMTP_PASS=your-password
87
87
  ```
88
88
 
89
+ ### Resend
90
+
91
+ If you prefer Resend, configure the following and initialize the service with `EmailImplType.Resend`:
92
+
93
+ ```bash
94
+ RESEND_API_KEY=your-resend-api-key
95
+ RESEND_FROM=noreply@example.com
96
+ ```
97
+
98
+ `RESEND_FROM` is used when `from` is not provided in the payload.
99
+
100
+ ### Choose Provider by Environment
101
+
102
+ Set `EMAIL_SERVICE_PROVIDER` to `nodemailer` or `resend` to pick the default implementation without changing code:
103
+
104
+ ```bash
105
+ EMAIL_SERVICE_PROVIDER=resend
106
+ ```
107
+
89
108
  ### Using Well-Known Services
90
109
 
91
110
  You can also use well-known email services (Gmail, SendGrid, etc.):
@@ -1,4 +1,5 @@
1
1
  import { NodemailerImpl } from './nodemailer';
2
+ import { ResendImpl } from './resend';
2
3
  import { EmailServiceImpl } from './type';
3
4
 
4
5
  /**
@@ -6,8 +7,8 @@ import { EmailServiceImpl } from './type';
6
7
  */
7
8
  export enum EmailImplType {
8
9
  Nodemailer = 'nodemailer',
10
+ Resend = 'resend',
9
11
  // Future providers can be added here:
10
- // Resend = 'resend',
11
12
  // SendGrid = 'sendgrid',
12
13
  }
13
14
 
@@ -21,6 +22,9 @@ export const createEmailServiceImpl = (
21
22
  case EmailImplType.Nodemailer: {
22
23
  return new NodemailerImpl();
23
24
  }
25
+ case EmailImplType.Resend: {
26
+ return new ResendImpl();
27
+ }
24
28
 
25
29
  default: {
26
30
  return new NodemailerImpl();
@@ -0,0 +1,120 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import debug from 'debug';
3
+ import { Resend } from 'resend';
4
+ import type { CreateEmailOptions } from 'resend';
5
+
6
+ import { emailEnv } from '@/envs/email';
7
+
8
+ import { EmailPayload, EmailResponse, EmailServiceImpl } from '../type';
9
+
10
+ const log = debug('lobe-email:Resend');
11
+
12
+ /**
13
+ * Resend implementation of the email service
14
+ */
15
+ export class ResendImpl implements EmailServiceImpl {
16
+ private client: Resend;
17
+
18
+ constructor() {
19
+ if (!emailEnv.RESEND_API_KEY) {
20
+ throw new Error(
21
+ 'RESEND_API_KEY environment variable is required to use Resend email service. Please configure it in your .env file.',
22
+ );
23
+ }
24
+
25
+ this.client = new Resend(emailEnv.RESEND_API_KEY);
26
+ log('Initialized Resend client');
27
+ }
28
+
29
+ async sendMail(payload: EmailPayload): Promise<EmailResponse> {
30
+ const from = payload.from ?? emailEnv.RESEND_FROM;
31
+ const html = payload.html;
32
+ const text = payload.text;
33
+
34
+ if (!from) {
35
+ throw new TRPCError({
36
+ code: 'PRECONDITION_FAILED',
37
+ message: 'Missing sender address. Provide payload.from or RESEND_FROM environment variable.',
38
+ });
39
+ }
40
+
41
+ if (!html && !text) {
42
+ throw new TRPCError({
43
+ code: 'PRECONDITION_FAILED',
44
+ message: 'Resend requires either html or text content in the email payload.',
45
+ });
46
+ }
47
+
48
+ const attachments = payload.attachments?.map((attachment) => {
49
+ if (attachment.content instanceof Buffer) {
50
+ return {
51
+ ...attachment,
52
+ content: attachment.content.toString('base64'),
53
+ };
54
+ }
55
+
56
+ return attachment;
57
+ });
58
+
59
+ try {
60
+ log('Sending email via Resend: %o', {
61
+ from,
62
+ subject: payload.subject,
63
+ to: payload.to,
64
+ });
65
+
66
+ const emailOptions: CreateEmailOptions = html
67
+ ? {
68
+ attachments,
69
+ from,
70
+ html,
71
+ replyTo: payload.replyTo,
72
+ subject: payload.subject,
73
+ text,
74
+ to: payload.to,
75
+ }
76
+ : {
77
+ attachments,
78
+ from,
79
+ replyTo: payload.replyTo,
80
+ subject: payload.subject,
81
+ text: text!,
82
+ to: payload.to,
83
+ };
84
+
85
+ const { data, error } = await this.client.emails.send(emailOptions);
86
+
87
+ if (error) {
88
+ log.extend('error')('Failed to send email via Resend: %o', error);
89
+ throw new TRPCError({
90
+ cause: error,
91
+ code: 'SERVICE_UNAVAILABLE',
92
+ message: `Failed to send email via Resend: ${error.message}`,
93
+ });
94
+ }
95
+
96
+ if (!data?.id) {
97
+ log.extend('error')('Resend sendMail returned no message id: %o', data);
98
+ throw new TRPCError({
99
+ code: 'SERVICE_UNAVAILABLE',
100
+ message: 'Failed to send email via Resend: missing message id',
101
+ });
102
+ }
103
+
104
+ return {
105
+ messageId: data.id,
106
+ };
107
+ } catch (error) {
108
+ if (error instanceof TRPCError) {
109
+ throw error;
110
+ }
111
+
112
+ log.extend('error')('Unexpected Resend sendMail error: %o', error);
113
+ throw new TRPCError({
114
+ cause: error,
115
+ code: 'SERVICE_UNAVAILABLE',
116
+ message: `Failed to send email via Resend: ${(error as Error).message}`,
117
+ });
118
+ }
119
+ }
120
+ }
@@ -26,7 +26,7 @@ describe('EmailService', () => {
26
26
 
27
27
  describe('constructor', () => {
28
28
  it('should create instance with default email implementation', () => {
29
- expect(createEmailServiceImpl).toHaveBeenCalledWith(undefined);
29
+ expect(createEmailServiceImpl).toHaveBeenCalledWith(EmailImplType.Nodemailer);
30
30
  });
31
31
 
32
32
  it('should create instance with specified implementation type', () => {
@@ -1,3 +1,4 @@
1
+ import { emailEnv } from '@/envs/email';
1
2
 
2
3
  import { EmailImplType, EmailPayload, EmailResponse, createEmailServiceImpl } from './impls';
3
4
  import type { EmailServiceImpl } from './impls';
@@ -10,7 +11,14 @@ export class EmailService {
10
11
  private emailImpl: EmailServiceImpl;
11
12
 
12
13
  constructor(implType?: EmailImplType) {
13
- this.emailImpl = createEmailServiceImpl(implType);
14
+ // Avoid client-side access to server env when executed in browser-like test environments
15
+ const envImplType =
16
+ typeof window === 'undefined'
17
+ ? (emailEnv.EMAIL_SERVICE_PROVIDER as EmailImplType | undefined)
18
+ : undefined;
19
+ const resolvedImplType = implType ?? envImplType ?? EmailImplType.Nodemailer;
20
+
21
+ this.emailImpl = createEmailServiceImpl(resolvedImplType);
14
22
  }
15
23
 
16
24
  /**
@@ -5,11 +5,11 @@ import { S3StaticFileImpl } from './s3';
5
5
  import { FileServiceImpl } from './type';
6
6
 
7
7
  /**
8
- * 创建文件服务模块
9
- * 根据环境自动选择使用S3或桌面本地文件实现
8
+ * Create file service module
9
+ * Automatically selects between S3 or desktop local file implementation based on environment
10
10
  */
11
11
  export const createFileServiceModule = (): FileServiceImpl => {
12
- // 如果在桌面应用环境,使用本地文件实现
12
+ // If in desktop application environment, use local file implementation
13
13
  if (isDesktop) {
14
14
  return new DesktopLocalFileImpl();
15
15
  }
@@ -12,12 +12,12 @@ import { extractKeyFromUrlOrReturnOriginal } from './utils';
12
12
  const log = debug('lobe-file:desktop-local');
13
13
 
14
14
  /**
15
- * 桌面应用本地文件服务实现
15
+ * Desktop application local file service implementation
16
16
  */
17
17
  export class DesktopLocalFileImpl implements FileServiceImpl {
18
18
  /**
19
- * 获取本地文件的URL
20
- * 通过 IPC 从主进程获取 HTTP URL
19
+ * Get local file URL
20
+ * Retrieve HTTP URL from main process via IPC
21
21
  */
22
22
  private async getLocalFileUrl(key: string): Promise<string> {
23
23
  try {
@@ -29,16 +29,16 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
29
29
  }
30
30
 
31
31
  /**
32
- * 创建预签名上传URL(本地版实际上是直接返回文件路径,可能需要进一步扩展)
32
+ * Create pre-signed upload URL (local version directly returns file path, may need further extension)
33
33
  */
34
34
  async createPreSignedUrl(key: string): Promise<string> {
35
- // 在桌面应用本地文件实现中,不需要预签名URL
36
- // 直接返回文件路径
35
+ // In desktop application local file implementation, pre-signed URL is not needed
36
+ // Directly return the file path
37
37
  return key;
38
38
  }
39
39
 
40
40
  /**
41
- * 创建预签名预览URL(本地版是通过HTTP路径访问本地文件)
41
+ * Create pre-signed preview URL (local version accesses local files via HTTP path)
42
42
  */
43
43
  async createPreSignedUrlForPreview(key: string): Promise<string> {
44
44
  return this.getLocalFileUrl(key);
@@ -49,13 +49,13 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
49
49
  }
50
50
 
51
51
  /**
52
- * 批量删除文件
52
+ * Delete files in batch
53
53
  */
54
54
  async deleteFiles(keys: string[]): Promise<any> {
55
55
  try {
56
56
  if (!keys || keys.length === 0) return { success: true };
57
57
 
58
- // 确保所有路径都是合法的desktop://路径
58
+ // Ensure all paths are valid desktop:// paths
59
59
  const invalidKeys = keys.filter((key) => !key.startsWith('desktop://'));
60
60
  if (invalidKeys.length > 0) {
61
61
  console.error('Invalid desktop file paths:', invalidKeys);
@@ -65,7 +65,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
65
65
  };
66
66
  }
67
67
 
68
- // 使用electronIpcClient的专用方法
68
+ // Use electronIpcClient's dedicated method
69
69
  return await electronIpcClient.deleteFiles(keys);
70
70
  } catch (error) {
71
71
  console.error('Failed to delete files:', error);
@@ -82,20 +82,20 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
82
82
  }
83
83
 
84
84
  /**
85
- * 获取文件字节数组
85
+ * Get file byte array
86
86
  */
87
87
  async getFileByteArray(key: string): Promise<Uint8Array> {
88
88
  try {
89
- // Electron获取文件的绝对路径
89
+ // Get absolute file path from Electron
90
90
  const filePath = await electronIpcClient.getFilePathById(key);
91
91
 
92
- // 检查文件是否存在
92
+ // Check if file exists
93
93
  if (!existsSync(filePath)) {
94
94
  console.error(`File not found: ${filePath}`);
95
95
  return new Uint8Array();
96
96
  }
97
97
 
98
- // 读取文件内容并转换为Uint8Array
98
+ // Read file content and convert to Uint8Array
99
99
  const buffer = readFileSync(filePath);
100
100
  return new Uint8Array(buffer);
101
101
  } catch (e) {
@@ -105,20 +105,20 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
105
105
  }
106
106
 
107
107
  /**
108
- * 获取文件内容
108
+ * Get file content
109
109
  */
110
110
  async getFileContent(key: string): Promise<string> {
111
111
  try {
112
- // Electron获取文件的绝对路径
112
+ // Get absolute file path from Electron
113
113
  const filePath = await electronIpcClient.getFilePathById(key);
114
114
 
115
- // 检查文件是否存在
115
+ // Check if file exists
116
116
  if (!existsSync(filePath)) {
117
117
  console.error(`File not found: ${filePath}`);
118
118
  return '';
119
119
  }
120
120
 
121
- // 读取文件内容并转换为字符串
121
+ // Read file content and convert to string
122
122
  return readFileSync(filePath, 'utf8');
123
123
  } catch (e) {
124
124
  console.error('Failed to get file content:', e);
@@ -127,7 +127,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
127
127
  }
128
128
 
129
129
  /**
130
- * 获取完整文件URL
130
+ * Get full file URL
131
131
  */
132
132
  async getFullFileUrl(url?: string | null): Promise<string> {
133
133
  if (!url) return '';
@@ -139,32 +139,32 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
139
139
  }
140
140
 
141
141
  /**
142
- * 上传内容
143
- * 注意:这个功能可能需要扩展Electron IPC接口
142
+ * Upload content
143
+ * Note: This feature may require extension of Electron IPC interface
144
144
  */
145
145
  async uploadContent(filePath: string, content: string): Promise<any> {
146
- // 这里需要扩展electronIpcClient以支持上传文件内容
147
- // 例如: return electronIpcClient.uploadContent(filePath, content);
146
+ // Need to extend electronIpcClient to support uploading file content
147
+ // For example: return electronIpcClient.uploadContent(filePath, content);
148
148
  console.warn('uploadContent not implemented for Desktop local file service', filePath, content);
149
149
  return;
150
150
  }
151
151
 
152
152
  /**
153
- * 从完整URL中提取key
154
- * HTTP URL 中提取 desktop:// 格式的路径
153
+ * Extract key from full URL
154
+ * Extract desktop:// format path from HTTP URL
155
155
  */
156
156
  getKeyFromFullUrl(url: string): string {
157
157
  try {
158
158
  const urlObj = new URL(url);
159
159
  const pathSegments = urlObj.pathname.split('/').filter((segment) => segment !== '');
160
160
 
161
- // 移除第一个路径段(desktop-file
161
+ // Remove first path segment (desktop-file)
162
162
  pathSegments.shift();
163
163
 
164
- // 重新组合剩余的路径段
164
+ // Recombine remaining path segments
165
165
  const filePath = pathSegments.join('/');
166
166
 
167
- // 返回 desktop:// 格式的路径
167
+ // Return desktop:// format path
168
168
  return `desktop://${filePath}`;
169
169
  } catch (e) {
170
170
  console.error('[DesktopLocalFileImpl] Failed to extract key from URL:', e);
@@ -173,23 +173,23 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
173
173
  }
174
174
 
175
175
  /**
176
- * 上传媒体文件
176
+ * Upload media file
177
177
  */
178
178
  async uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }> {
179
179
  try {
180
- // Buffer 转换为 Base64 字符串
180
+ // Convert Buffer to Base64 string
181
181
  const content = buffer.toString('base64');
182
182
 
183
- // key 中提取文件名
183
+ // Extract filename from key
184
184
  const filename = path.basename(key);
185
185
 
186
- // 计算文件的 SHA256 hash
186
+ // Calculate SHA256 hash of the file
187
187
  const hash = sha256(buffer);
188
188
 
189
- // 根据文件URL推断 MIME 类型
189
+ // Infer MIME type from file URL
190
190
  const type = inferContentTypeFromImageUrl(key)!;
191
191
 
192
- // 构造上传参数
192
+ // Construct upload parameters
193
193
  const uploadParams = {
194
194
  content,
195
195
  filename,
@@ -198,7 +198,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
198
198
  type,
199
199
  };
200
200
 
201
- // 调用 electronIpcClient 上传文件
201
+ // Call electronIpcClient to upload file
202
202
  const result = await electronIpcClient.createFile(uploadParams);
203
203
 
204
204
  if (!result.success) {
@@ -7,7 +7,7 @@ import { FileServiceImpl } from './type';
7
7
  import { extractKeyFromUrlOrReturnOriginal } from './utils';
8
8
 
9
9
  /**
10
- * 基于S3的文件服务实现
10
+ * S3-based file service implementation
11
11
  */
12
12
  export class S3StaticFileImpl implements FileServiceImpl {
13
13
  private readonly s3: S3;
@@ -1,54 +1,54 @@
1
1
  /**
2
- * S3文件服务实现
2
+ * File service implementation interface
3
3
  */
4
4
  export interface FileServiceImpl {
5
5
  /**
6
- * 创建预签名上传URL
6
+ * Create pre-signed upload URL
7
7
  */
8
8
  createPreSignedUrl(key: string): Promise<string>;
9
9
 
10
10
  /**
11
- * 创建预签名预览URL
11
+ * Create pre-signed preview URL
12
12
  */
13
13
  createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string>;
14
14
 
15
15
  /**
16
- * 删除文件
16
+ * Delete file
17
17
  */
18
18
  deleteFile(key: string): Promise<any>;
19
19
 
20
20
  /**
21
- * 批量删除文件
21
+ * Delete files in batch
22
22
  */
23
23
  deleteFiles(keys: string[]): Promise<any>;
24
24
 
25
25
  /**
26
- * 获取文件字节数组
26
+ * Get file byte array
27
27
  */
28
28
  getFileByteArray(key: string): Promise<Uint8Array>;
29
29
 
30
30
  /**
31
- * 获取文件内容
31
+ * Get file content
32
32
  */
33
33
  getFileContent(key: string): Promise<string>;
34
34
 
35
35
  /**
36
- * 获取完整文件URL
36
+ * Get full file URL
37
37
  */
38
38
  getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string>;
39
39
 
40
40
  /**
41
- * 从完整URL中提取key
41
+ * Extract key from full URL
42
42
  */
43
43
  getKeyFromFullUrl(url: string): string;
44
44
 
45
45
  /**
46
- * 上传内容
46
+ * Upload content
47
47
  */
48
48
  uploadContent(path: string, content: string): Promise<any>;
49
49
 
50
50
  /**
51
- * 上传媒体文件
51
+ * Upload media file
52
52
  */
53
53
  uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }>;
54
54
  }
@@ -11,8 +11,8 @@ import { TempFileManager } from '@/server/utils/tempFileManager';
11
11
  import { FileServiceImpl, createFileServiceModule } from './impls';
12
12
 
13
13
  /**
14
- * 文件服务类
15
- * 使用模块化实现方式,提供文件操作服务
14
+ * File service class
15
+ * Provides file operation services using a modular implementation approach
16
16
  */
17
17
  export class FileService {
18
18
  private userId: string;
@@ -26,70 +26,70 @@ export class FileService {
26
26
  }
27
27
 
28
28
  /**
29
- * 删除文件
29
+ * Delete file
30
30
  */
31
31
  public async deleteFile(key: string) {
32
32
  return this.impl.deleteFile(key);
33
33
  }
34
34
 
35
35
  /**
36
- * 批量删除文件
36
+ * Delete files in batch
37
37
  */
38
38
  public async deleteFiles(keys: string[]) {
39
39
  return this.impl.deleteFiles(keys);
40
40
  }
41
41
 
42
42
  /**
43
- * 获取文件内容
43
+ * Get file content
44
44
  */
45
45
  public async getFileContent(key: string): Promise<string> {
46
46
  return this.impl.getFileContent(key);
47
47
  }
48
48
 
49
49
  /**
50
- * 获取文件字节数组
50
+ * Get file byte array
51
51
  */
52
52
  public async getFileByteArray(key: string): Promise<Uint8Array> {
53
53
  return this.impl.getFileByteArray(key);
54
54
  }
55
55
 
56
56
  /**
57
- * 创建预签名上传URL
57
+ * Create pre-signed upload URL
58
58
  */
59
59
  public async createPreSignedUrl(key: string): Promise<string> {
60
60
  return this.impl.createPreSignedUrl(key);
61
61
  }
62
62
 
63
63
  /**
64
- * 创建预签名预览URL
64
+ * Create pre-signed preview URL
65
65
  */
66
66
  public async createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string> {
67
67
  return this.impl.createPreSignedUrlForPreview(key, expiresIn);
68
68
  }
69
69
 
70
70
  /**
71
- * 上传内容
71
+ * Upload content
72
72
  */
73
73
  public async uploadContent(path: string, content: string) {
74
74
  return this.impl.uploadContent(path, content);
75
75
  }
76
76
 
77
77
  /**
78
- * 获取完整文件URL
78
+ * Get full file URL
79
79
  */
80
80
  public async getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string> {
81
81
  return this.impl.getFullFileUrl(url, expiresIn);
82
82
  }
83
83
 
84
84
  /**
85
- * 从完整 URL中 提取 key
85
+ * Extract key from full URL
86
86
  */
87
87
  public getKeyFromFullUrl(url: string): string {
88
88
  return this.impl.getKeyFromFullUrl(url);
89
89
  }
90
90
 
91
91
  /**
92
- * 上传媒体文件
92
+ * Upload media file
93
93
  */
94
94
  public async uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }> {
95
95
  return this.impl.uploadMedia(key, buffer);