@lobehub/lobehub 2.0.0-next.123 → 2.0.0-next.125

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.
Files changed (126) hide show
  1. package/.cursor/rules/db-migrations.mdc +16 -1
  2. package/.cursor/rules/project-introduce.mdc +1 -1
  3. package/.cursor/rules/project-structure.mdc +20 -2
  4. package/.env.example +148 -65
  5. package/.env.example.development +6 -8
  6. package/AGENTS.md +1 -3
  7. package/CHANGELOG.md +51 -0
  8. package/Dockerfile +6 -6
  9. package/GEMINI.md +63 -0
  10. package/README.md +8 -8
  11. package/README.zh-CN.md +8 -8
  12. package/changelog/v1.json +18 -0
  13. package/docs/development/database-schema.dbml +38 -0
  14. package/docs/self-hosting/advanced/auth.mdx +75 -2
  15. package/docs/self-hosting/advanced/auth.zh-CN.mdx +75 -2
  16. package/docs/self-hosting/environment-variables/auth.mdx +187 -1
  17. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
  18. package/locales/en-US/auth.json +93 -0
  19. package/locales/zh-CN/auth.json +107 -1
  20. package/package.json +5 -2
  21. package/packages/const/src/auth.ts +2 -1
  22. package/packages/database/migrations/0048_add_editor_data.sql +1 -0
  23. package/packages/database/migrations/0049_better_auth.sql +49 -0
  24. package/packages/database/migrations/meta/0048_snapshot.json +7913 -0
  25. package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
  26. package/packages/database/migrations/meta/_journal.json +14 -0
  27. package/packages/database/src/core/migrations.json +19 -0
  28. package/packages/database/src/index.ts +1 -0
  29. package/packages/database/src/models/__tests__/session.test.ts +1 -2
  30. package/packages/database/src/models/user.ts +9 -8
  31. package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
  32. package/packages/database/src/schemas/agent.ts +1 -0
  33. package/packages/database/src/schemas/betterAuth.ts +63 -0
  34. package/packages/database/src/schemas/index.ts +1 -0
  35. package/packages/database/src/schemas/ragEvals.ts +1 -2
  36. package/packages/database/src/schemas/user.ts +3 -2
  37. package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
  38. package/packages/types/src/user/preference.ts +11 -0
  39. package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
  40. package/packages/utils/src/server/auth.ts +18 -1
  41. package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
  42. package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
  43. package/src/app/(backend)/middleware/auth/index.ts +14 -0
  44. package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
  45. package/src/app/(backend)/middleware/auth/utils.ts +13 -10
  46. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
  47. package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
  48. package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
  49. package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
  50. package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
  51. package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
  52. package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
  53. package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
  54. package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
  55. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
  56. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
  57. package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/TopicContent.tsx +15 -8
  58. package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/index.tsx +27 -30
  59. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
  60. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
  61. package/src/auth.ts +118 -0
  62. package/src/components/NextAuth/AuthIcons.tsx +3 -1
  63. package/src/envs/auth.ts +260 -13
  64. package/src/envs/email.ts +37 -0
  65. package/src/features/AgentSetting/AgentPlugin/index.tsx +6 -2
  66. package/src/features/User/UserPanel/PanelContent.tsx +6 -5
  67. package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
  68. package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
  69. package/src/features/User/__tests__/useMenu.test.tsx +14 -12
  70. package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
  71. package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
  72. package/src/layout/AuthProvider/index.tsx +3 -0
  73. package/src/layout/GlobalProvider/StoreInitialization.tsx +3 -3
  74. package/src/libs/better-auth/auth-client.ts +34 -0
  75. package/src/libs/better-auth/constants.ts +13 -0
  76. package/src/libs/better-auth/email-templates/index.ts +3 -0
  77. package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
  78. package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
  79. package/src/libs/better-auth/email-templates/verification.ts +108 -0
  80. package/src/libs/better-auth/sso/helpers.ts +61 -0
  81. package/src/libs/better-auth/sso/index.ts +113 -0
  82. package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
  83. package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
  84. package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
  85. package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
  86. package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
  87. package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
  88. package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
  89. package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
  90. package/src/libs/better-auth/sso/providers/github.ts +30 -0
  91. package/src/libs/better-auth/sso/providers/google.ts +30 -0
  92. package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
  93. package/src/libs/better-auth/sso/providers/logto.ts +38 -0
  94. package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
  95. package/src/libs/better-auth/sso/providers/okta.ts +37 -0
  96. package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
  97. package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
  98. package/src/libs/better-auth/sso/types.ts +25 -0
  99. package/src/libs/better-auth/utils/client.ts +1 -0
  100. package/src/libs/better-auth/utils/common.ts +20 -0
  101. package/src/libs/better-auth/utils/server.test.ts +61 -0
  102. package/src/libs/better-auth/utils/server.ts +18 -0
  103. package/src/libs/trpc/lambda/context.test.ts +116 -0
  104. package/src/libs/trpc/lambda/context.ts +27 -0
  105. package/src/libs/trpc/middleware/userAuth.ts +4 -2
  106. package/src/locales/default/auth.ts +114 -1
  107. package/src/proxy.ts +71 -7
  108. package/src/server/globalConfig/index.ts +12 -1
  109. package/src/server/routers/lambda/user.ts +4 -0
  110. package/src/server/services/email/README.md +241 -0
  111. package/src/server/services/email/impls/index.test.ts +39 -0
  112. package/src/server/services/email/impls/index.ts +32 -0
  113. package/src/server/services/email/impls/nodemailer/index.ts +108 -0
  114. package/src/server/services/email/impls/nodemailer/type.ts +31 -0
  115. package/src/server/services/email/impls/type.ts +61 -0
  116. package/src/server/services/email/index.test.ts +144 -0
  117. package/src/server/services/email/index.ts +40 -0
  118. package/src/services/user/index.test.ts +162 -2
  119. package/src/services/user/index.ts +6 -3
  120. package/src/store/aiInfra/slices/aiProvider/action.ts +4 -4
  121. package/src/store/user/slices/auth/action.test.ts +213 -16
  122. package/src/store/user/slices/auth/action.ts +86 -1
  123. package/src/store/user/slices/auth/initialState.ts +13 -2
  124. package/src/store/user/slices/auth/selectors.ts +6 -2
  125. package/src/store/user/slices/common/action.ts +5 -1
  126. package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
package/src/proxy.ts CHANGED
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
5
5
  import { UAParser } from 'ua-parser-js';
6
6
  import urlJoin from 'url-join';
7
7
 
8
+ import { auth } from '@/auth';
8
9
  import { OAUTH_AUTHORIZED } from '@/const/auth';
9
10
  import { LOBE_LOCALE_COOKIE } from '@/const/locale';
10
11
  import { LOBE_THEME_APPEARANCE } from '@/const/theme';
@@ -21,6 +22,7 @@ import { RouteVariants } from './utils/server/routeVariants';
21
22
  const logDefault = debug('middleware:default');
22
23
  const logNextAuth = debug('middleware:next-auth');
23
24
  const logClerk = debug('middleware:clerk');
25
+ const logBetterAuth = debug('middleware:better-auth');
24
26
 
25
27
  // OIDC session pre-sync constant
26
28
  const OIDC_SESSION_HEADER = 'x-oidc-session-sync';
@@ -47,10 +49,12 @@ export const config = {
47
49
 
48
50
  '/login(.*)',
49
51
  '/signup(.*)',
52
+ '/signin(.*)',
53
+ '/verify-email(.*)',
54
+ '/reset-password(.*)',
50
55
  '/next-auth/(.*)',
51
56
  '/oauth(.*)',
52
57
  '/oidc(.*)',
53
- // ↓ cloud ↓
54
58
  ],
55
59
  };
56
60
 
@@ -129,8 +133,18 @@ const defaultMiddleware = (request: NextRequest) => {
129
133
  // / -> /zh-CN__0__dark
130
134
  // /discover -> /zh-CN__0__dark/discover
131
135
  // All SPA routes that use react-router-dom should be rewritten to just /${route}
132
- const spaRoutes = ['/chat', '/discover', '/knowledge', '/settings', '/image', '/labs', '/changelog', '/profile', '/me'];
133
- const isSpaRoute = spaRoutes.some(route => url.pathname.startsWith(route));
136
+ const spaRoutes = [
137
+ '/chat',
138
+ '/discover',
139
+ '/knowledge',
140
+ '/settings',
141
+ '/image',
142
+ '/labs',
143
+ '/changelog',
144
+ '/profile',
145
+ '/me',
146
+ ];
147
+ const isSpaRoute = spaRoutes.some((route) => url.pathname.startsWith(route));
134
148
 
135
149
  let nextPathname: string;
136
150
  if (isSpaRoute) {
@@ -142,7 +156,6 @@ const defaultMiddleware = (request: NextRequest) => {
142
156
  ? urlJoin(url.origin, nextPathname)
143
157
  : nextPathname;
144
158
 
145
-
146
159
  console.log('nextURL', nextURL);
147
160
 
148
161
  logDefault('URL rewrite: %O', {
@@ -194,6 +207,10 @@ const isPublicRoute = createRouteMatcher([
194
207
  // clerk
195
208
  '/login',
196
209
  '/signup',
210
+ // better auth
211
+ '/signin',
212
+ '/verify-email',
213
+ '/reset-password',
197
214
  // oauth
198
215
  // Make only the consent view public (GET page), not other oauth paths
199
216
  '/oauth/consent/(.*)',
@@ -304,8 +321,53 @@ const clerkAuthMiddleware = clerkMiddleware(
304
321
  },
305
322
  );
306
323
 
324
+ const betterAuthMiddleware = async (req: NextRequest) => {
325
+ logBetterAuth('BetterAuth middleware processing request: %s %s', req.method, req.url);
326
+
327
+ const response = defaultMiddleware(req);
328
+
329
+ // when enable auth protection, only public route is not protected, others are all protected
330
+ const isProtected = appEnv.ENABLE_AUTH_PROTECTION ? !isPublicRoute(req) : isProtectedRoute(req);
331
+
332
+ logBetterAuth('Route protection status: %s, %s', req.url, isProtected ? 'protected' : 'public');
333
+
334
+ // Skip session lookup for public routes to reduce latency
335
+ if (!isProtected) return response;
336
+
337
+ // Get full session with user data (Next.js 15.2.0+ feature)
338
+ const session = await auth.api.getSession({
339
+ headers: req.headers,
340
+ });
341
+
342
+ const isLoggedIn = !!session?.user;
343
+
344
+ logBetterAuth('BetterAuth session status: %O', {
345
+ isLoggedIn,
346
+ userId: session?.user?.id,
347
+ });
348
+
349
+ if (!isLoggedIn) {
350
+ // If request a protected route, redirect to sign-in page
351
+ if (isProtected) {
352
+ logBetterAuth('Request a protected route, redirecting to sign-in page');
353
+ const signInUrl = new URL('/signin', req.nextUrl.origin);
354
+ signInUrl.searchParams.set('callbackUrl', req.nextUrl.href);
355
+ const hl = req.nextUrl.searchParams.get('hl');
356
+ if (hl) {
357
+ signInUrl.searchParams.set('hl', hl);
358
+ logBetterAuth('Preserving locale to sign-in: hl=%s', hl);
359
+ }
360
+ return Response.redirect(signInUrl);
361
+ }
362
+ logBetterAuth('Request a free route but not login, allow visit without auth header');
363
+ }
364
+
365
+ return response;
366
+ };
367
+
307
368
  logDefault('Middleware configuration: %O', {
308
369
  enableAuthProtection: appEnv.ENABLE_AUTH_PROTECTION,
370
+ enableBetterAuth: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH,
309
371
  enableClerk: authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH,
310
372
  enableNextAuth: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH,
311
373
  enableOIDC: oidcEnv.ENABLE_OIDC,
@@ -313,6 +375,8 @@ logDefault('Middleware configuration: %O', {
313
375
 
314
376
  export default authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH
315
377
  ? clerkAuthMiddleware
316
- : authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH
317
- ? nextAuthMiddleware
318
- : defaultMiddleware;
378
+ : authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH
379
+ ? betterAuthMiddleware
380
+ : authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH
381
+ ? nextAuthMiddleware
382
+ : defaultMiddleware;
@@ -5,6 +5,7 @@ import { fileEnv } from '@/envs/file';
5
5
  import { imageEnv } from '@/envs/image';
6
6
  import { knowledgeEnv } from '@/envs/knowledge';
7
7
  import { langfuseEnv } from '@/envs/langfuse';
8
+ import { parseSSOProviders } from '@/libs/better-auth/utils/server';
8
9
  import { parseSystemAgent } from '@/server/globalConfig/parseSystemAgent';
9
10
  import { GlobalServerConfig } from '@/types/serverConfig';
10
11
  import { cleanObject } from '@/utils/object';
@@ -13,6 +14,14 @@ import { genServerAiProvidersConfig } from './genServerAiProviderConfig';
13
14
  import { parseAgentConfig } from './parseDefaultAgent';
14
15
  import { parseFilesConfig } from './parseFilesConfig';
15
16
 
17
+ /**
18
+ * Get Better-Auth SSO providers list
19
+ * Parses AUTH_SSO_PROVIDERS and returns enabled providers
20
+ */
21
+ const getBetterAuthSSOProviders = () => {
22
+ return parseSSOProviders(authEnv.AUTH_SSO_PROVIDERS);
23
+ };
24
+
16
25
  export const getServerGlobalConfig = async () => {
17
26
  const { ACCESS_CODES, DEFAULT_AGENT_CONFIG } = getAppConfig();
18
27
 
@@ -63,7 +72,9 @@ export const getServerGlobalConfig = async () => {
63
72
  image: cleanObject({
64
73
  defaultImageNum: imageEnv.AI_IMAGE_DEFAULT_IMAGE_NUM,
65
74
  }),
66
- oAuthSSOProviders: authEnv.NEXT_AUTH_SSO_PROVIDERS.trim().split(/[,,]/),
75
+ oAuthSSOProviders: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH
76
+ ? getBetterAuthSSOProviders()
77
+ : authEnv.NEXT_AUTH_SSO_PROVIDERS.trim().split(/[,,]/),
67
78
  systemAgent: parseSystemAgent(appEnv.SYSTEM_AGENT),
68
79
  telemetry: {
69
80
  langfuse: langfuseEnv.ENABLE_LANGFUSE,
@@ -198,6 +198,10 @@ export const userRouter = router({
198
198
  return ctx.userModel.updateUser({ avatar: input });
199
199
  }),
200
200
 
201
+ updateFullName: userProcedure.input(z.string()).mutation(async ({ ctx, input }) => {
202
+ return ctx.userModel.updateUser({ fullName: input });
203
+ }),
204
+
201
205
  updateGuide: userProcedure.input(UserGuideSchema).mutation(async ({ ctx, input }) => {
202
206
  return ctx.userModel.updateGuide(input);
203
207
  }),
@@ -0,0 +1,241 @@
1
+ # Email Service
2
+
3
+ A flexible email service implementation supporting multiple email providers.
4
+
5
+ ## Architecture
6
+
7
+ Based on the search service pattern, this service provides a unified interface for sending emails across different providers.
8
+
9
+ ```plaintext
10
+ EmailService
11
+ └── EmailServiceImpl (interface)
12
+ └── NodemailerImpl (SMTP provider)
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Basic Example
18
+
19
+ ```typescript
20
+ import { EmailService } from '@/server/services/email';
21
+
22
+ const emailService = new EmailService();
23
+
24
+ // Send a simple text email
25
+ await emailService.sendMail({
26
+ from: 'noreply@example.com',
27
+ to: 'user@example.com',
28
+ subject: 'Welcome to LobeChat',
29
+ text: 'Thanks for signing up!',
30
+ html: '<p>Thanks for signing up!</p>',
31
+ });
32
+ ```
33
+
34
+ ### With Multiple Recipients
35
+
36
+ ```typescript
37
+ await emailService.sendMail({
38
+ from: 'team@example.com',
39
+ to: ['user1@example.com', 'user2@example.com'],
40
+ subject: 'Team Update',
41
+ text: 'Check out our latest updates',
42
+ });
43
+ ```
44
+
45
+ ### With Attachments
46
+
47
+ ```typescript
48
+ await emailService.sendMail({
49
+ from: 'support@example.com',
50
+ to: 'user@example.com',
51
+ subject: 'Your Invoice',
52
+ text: 'Please find your invoice attached.',
53
+ attachments: [
54
+ {
55
+ filename: 'invoice.pdf',
56
+ path: '/path/to/invoice.pdf',
57
+ },
58
+ ],
59
+ });
60
+ ```
61
+
62
+ ### With Reply-To Address
63
+
64
+ ```typescript
65
+ await emailService.sendMail({
66
+ from: 'noreply@example.com',
67
+ replyTo: 'support@example.com',
68
+ to: 'user@example.com',
69
+ subject: 'Contact Us',
70
+ text: 'Reply to this email for support.',
71
+ });
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ ### Environment Variables
77
+
78
+ Configure SMTP settings using environment variables:
79
+
80
+ ```bash
81
+ # SMTP Server Configuration
82
+ SMTP_HOST=smtp.example.com
83
+ SMTP_PORT=587
84
+ SMTP_SECURE=false # true for port 465, false for other ports
85
+ SMTP_USER=your-username
86
+ SMTP_PASS=your-password
87
+ ```
88
+
89
+ ### Using Well-Known Services
90
+
91
+ You can also use well-known email services (Gmail, SendGrid, etc.):
92
+
93
+ ```typescript
94
+ import { EmailImplType, EmailService } from '@/server/services/email';
95
+ import { NodemailerImpl } from '@/server/services/email/impls/nodemailer';
96
+
97
+ const emailService = new EmailService(EmailImplType.Nodemailer);
98
+ // Configure in constructor with service name
99
+ ```
100
+
101
+ ### Testing with Ethereal
102
+
103
+ For development and testing, use [Ethereal Email](https://ethereal.email/):
104
+
105
+ ```typescript
106
+ // The preview URL will be logged automatically in development
107
+ const result = await emailService.sendMail({...});
108
+ console.log('Preview URL:', result.previewUrl);
109
+ ```
110
+
111
+ ## Verify Connection
112
+
113
+ Before sending emails, verify your SMTP configuration:
114
+
115
+ ```typescript
116
+ import { EmailService } from '@/server/services/email';
117
+
118
+ const emailService = new EmailService();
119
+
120
+ try {
121
+ await emailService.verify();
122
+ console.log('SMTP connection verified ✓');
123
+ } catch (error) {
124
+ console.error('SMTP verification failed:', error);
125
+ }
126
+ ```
127
+
128
+ ## Integration with Better-Auth
129
+
130
+ Example integration for email verification:
131
+
132
+ ```typescript
133
+ import { betterAuth } from 'better-auth';
134
+
135
+ import { EmailService } from '@/server/services/email';
136
+
137
+ export const auth = betterAuth({
138
+ emailAndPassword: {
139
+ enabled: true,
140
+ sendResetPasswordEmail: async ({ user, url }) => {
141
+ const emailService = new EmailService();
142
+
143
+ await emailService.sendMail({
144
+ from: 'noreply@lobechat.com',
145
+ to: user.email,
146
+ subject: 'Reset Your Password',
147
+ text: `Click here to reset your password: ${url}`,
148
+ html: `
149
+ <h1>Reset Your Password</h1>
150
+ <p>Click the link below to reset your password:</p>
151
+ <a href="${url}">Reset Password</a>
152
+ `,
153
+ });
154
+ },
155
+ },
156
+ emailVerification: {
157
+ enabled: true,
158
+ sendVerificationEmail: async ({ user, url }) => {
159
+ const emailService = new EmailService();
160
+
161
+ await emailService.sendMail({
162
+ from: 'noreply@lobechat.com',
163
+ to: user.email,
164
+ subject: 'Verify Your Email',
165
+ text: `Click here to verify your email: ${url}`,
166
+ html: `
167
+ <h1>Verify Your Email</h1>
168
+ <p>Click the link below to verify your email address:</p>
169
+ <a href="${url}">Verify Email</a>
170
+ `,
171
+ });
172
+ },
173
+ },
174
+ });
175
+ ```
176
+
177
+ ## Adding New Providers
178
+
179
+ To add a new email provider (e.g., Resend, SendGrid):
180
+
181
+ 1. Create provider implementation in `impls/[provider-name]/index.ts`:
182
+
183
+ ```typescript
184
+ import { EmailPayload, EmailResponse, EmailServiceImpl } from '../type';
185
+
186
+ export class ResendImpl implements EmailServiceImpl {
187
+ async sendMail(payload: EmailPayload): Promise<EmailResponse> {
188
+ // Implement using Resend API
189
+ }
190
+ }
191
+ ```
192
+
193
+ 2. Add to the enum in `impls/index.ts`:
194
+
195
+ ```typescript
196
+ export enum EmailImplType {
197
+ Nodemailer = 'nodemailer',
198
+ Resend = 'resend', // Add new provider
199
+ }
200
+ ```
201
+
202
+ 3. Update factory function in `impls/index.ts`:
203
+
204
+ ```typescript
205
+ export const createEmailServiceImpl = (type: EmailImplType) => {
206
+ switch (type) {
207
+ case EmailImplType.Nodemailer:
208
+ return new NodemailerImpl();
209
+ case EmailImplType.Resend:
210
+ return new ResendImpl();
211
+ default:
212
+ return new NodemailerImpl();
213
+ }
214
+ };
215
+ ```
216
+
217
+ ## Error Handling
218
+
219
+ The service throws `TRPCError` for various failure scenarios:
220
+
221
+ ```typescript
222
+ try {
223
+ await emailService.sendMail({...});
224
+ } catch (error) {
225
+ if (error.code === 'SERVICE_UNAVAILABLE') {
226
+ // Handle SMTP connection issues
227
+ } else if (error.code === 'PRECONDITION_FAILED') {
228
+ // Handle configuration errors
229
+ }
230
+ }
231
+ ```
232
+
233
+ ## Debugging
234
+
235
+ Enable debug logging:
236
+
237
+ ```bash
238
+ DEBUG=lobe-email:* node your-app.js
239
+ ```
240
+
241
+ This will log detailed information about email sending operations.
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { EmailImplType, createEmailServiceImpl } from './index';
4
+
5
+ vi.mock('./nodemailer', () => ({
6
+ NodemailerImpl: vi.fn().mockImplementation(() => ({
7
+ sendMail: vi.fn().mockResolvedValue({ messageId: 'test-id' }),
8
+ verify: vi.fn().mockResolvedValue(true),
9
+ })),
10
+ }));
11
+
12
+ describe('createEmailServiceImpl', () => {
13
+ it('should create NodemailerImpl by default', () => {
14
+ const impl = createEmailServiceImpl();
15
+
16
+ expect(impl).toBeDefined();
17
+ expect(impl.sendMail).toBeDefined();
18
+ });
19
+
20
+ it('should create NodemailerImpl when explicitly specified', () => {
21
+ const impl = createEmailServiceImpl(EmailImplType.Nodemailer);
22
+
23
+ expect(impl).toBeDefined();
24
+ expect(impl.sendMail).toBeDefined();
25
+ });
26
+
27
+ it('should fall back to NodemailerImpl for unknown type', () => {
28
+ const impl = createEmailServiceImpl('unknown' as EmailImplType);
29
+
30
+ expect(impl).toBeDefined();
31
+ expect(impl.sendMail).toBeDefined();
32
+ });
33
+ });
34
+
35
+ describe('EmailImplType enum', () => {
36
+ it('should have Nodemailer as a valid type', () => {
37
+ expect(EmailImplType.Nodemailer).toBe('nodemailer');
38
+ });
39
+ });
@@ -0,0 +1,32 @@
1
+ import { NodemailerImpl } from './nodemailer';
2
+ import { EmailServiceImpl } from './type';
3
+
4
+ /**
5
+ * Available email service implementations
6
+ */
7
+ export enum EmailImplType {
8
+ Nodemailer = 'nodemailer',
9
+ // Future providers can be added here:
10
+ // Resend = 'resend',
11
+ // SendGrid = 'sendgrid',
12
+ }
13
+
14
+ /**
15
+ * Create an email service implementation instance
16
+ */
17
+ export const createEmailServiceImpl = (
18
+ type: EmailImplType = EmailImplType.Nodemailer,
19
+ ): EmailServiceImpl => {
20
+ switch (type) {
21
+ case EmailImplType.Nodemailer: {
22
+ return new NodemailerImpl();
23
+ }
24
+
25
+ default: {
26
+ return new NodemailerImpl();
27
+ }
28
+ }
29
+ };
30
+
31
+ export type { EmailServiceImpl } from './type';
32
+ export type { EmailPayload, EmailResponse } from './type';
@@ -0,0 +1,108 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import debug from 'debug';
3
+ import nodemailer from 'nodemailer';
4
+ import type { Transporter } from 'nodemailer';
5
+
6
+ import { emailEnv } from '@/envs/email';
7
+
8
+ import { EmailPayload, EmailResponse, EmailServiceImpl } from '../type';
9
+ import { NodemailerConfig } from './type';
10
+
11
+ const log = debug('lobe-email:Nodemailer');
12
+
13
+ /**
14
+ * Nodemailer implementation of the email service
15
+ */
16
+ export class NodemailerImpl implements EmailServiceImpl {
17
+ private transporter: Transporter;
18
+
19
+ constructor() {
20
+ log('Initializing Nodemailer from environment variables');
21
+
22
+ if (!emailEnv.SMTP_USER || !emailEnv.SMTP_PASS) {
23
+ throw new Error(
24
+ 'SMTP_USER and SMTP_PASS environment variables are required to use email service. Please configure SMTP settings in your .env file.',
25
+ );
26
+ }
27
+
28
+ const transportConfig: NodemailerConfig = {
29
+ auth: {
30
+ pass: emailEnv.SMTP_PASS,
31
+ user: emailEnv.SMTP_USER,
32
+ },
33
+ host: emailEnv.SMTP_HOST ?? 'localhost',
34
+ port: emailEnv.SMTP_PORT ?? 587,
35
+ secure: emailEnv.SMTP_SECURE ?? false,
36
+ };
37
+
38
+ try {
39
+ this.transporter = nodemailer.createTransport(transportConfig);
40
+ log('Nodemailer transporter created successfully');
41
+ } catch (error) {
42
+ log.extend('error')('Failed to create Nodemailer transporter: %o', error);
43
+ throw new TRPCError({
44
+ cause: error,
45
+ code: 'INTERNAL_SERVER_ERROR',
46
+ message: 'Failed to initialize Nodemailer transport',
47
+ });
48
+ }
49
+ }
50
+
51
+ async sendMail(payload: EmailPayload): Promise<EmailResponse> {
52
+ // Use SMTP_USER as default sender if not provided
53
+ const from = payload.from ?? emailEnv.SMTP_USER!;
54
+
55
+ log('Sending email with payload: %o', {
56
+ from,
57
+ subject: payload.subject,
58
+ to: payload.to,
59
+ });
60
+
61
+ try {
62
+ const info = await this.transporter.sendMail({
63
+ attachments: payload.attachments,
64
+ from,
65
+ html: payload.html,
66
+ replyTo: payload.replyTo,
67
+ subject: payload.subject,
68
+ text: payload.text,
69
+ to: payload.to,
70
+ });
71
+
72
+ log('Email sent successfully with message ID: %s', info.messageId);
73
+
74
+ const previewUrl = nodemailer.getTestMessageUrl(info);
75
+
76
+ return {
77
+ messageId: info.messageId,
78
+ previewUrl: previewUrl || undefined,
79
+ };
80
+ } catch (error) {
81
+ log.extend('error')('Failed to send email: %o', error);
82
+ throw new TRPCError({
83
+ cause: error,
84
+ code: 'SERVICE_UNAVAILABLE',
85
+ message: `Failed to send email: ${(error as Error).message}`,
86
+ });
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Verify the SMTP connection configuration
92
+ */
93
+ async verify(): Promise<boolean> {
94
+ try {
95
+ log('Verifying SMTP connection...');
96
+ await this.transporter.verify();
97
+ log('SMTP connection verified successfully');
98
+ return true;
99
+ } catch (error) {
100
+ log.extend('error')('SMTP verification failed: %o', error);
101
+ throw new TRPCError({
102
+ cause: error,
103
+ code: 'SERVICE_UNAVAILABLE',
104
+ message: 'Failed to verify SMTP connection',
105
+ });
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Nodemailer SMTP transport configuration
3
+ */
4
+ export interface NodemailerConfig {
5
+ /**
6
+ * Authentication credentials
7
+ */
8
+ auth?: {
9
+ pass: string;
10
+ user: string;
11
+ };
12
+ /**
13
+ * SMTP server hostname
14
+ */
15
+ host?: string;
16
+ /**
17
+ * SMTP server port
18
+ * @default 587
19
+ */
20
+ port?: number;
21
+ /**
22
+ * Use TLS connection
23
+ * @default false
24
+ */
25
+ secure?: boolean;
26
+ /**
27
+ * Well-known service name (e.g., 'Gmail', 'SendGrid')
28
+ * When set, overrides host, port, and secure
29
+ */
30
+ service?: string;
31
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Email message payload
3
+ */
4
+ export interface EmailPayload {
5
+ /**
6
+ * Email attachments
7
+ */
8
+ attachments?: Array<{
9
+ content?: Buffer | string;
10
+ filename?: string;
11
+ path?: string;
12
+ }>;
13
+ /**
14
+ * Sender address (defaults to SMTP_USER if not provided)
15
+ */
16
+ from?: string;
17
+ /**
18
+ * HTML body of the email
19
+ */
20
+ html?: string;
21
+ /**
22
+ * Reply-To address
23
+ */
24
+ replyTo?: string;
25
+ /**
26
+ * Subject line
27
+ */
28
+ subject: string;
29
+ /**
30
+ * Plain text body of the email
31
+ */
32
+ text?: string;
33
+ /**
34
+ * Recipient address(es)
35
+ */
36
+ to: string | string[];
37
+ }
38
+
39
+ /**
40
+ * Email send response
41
+ */
42
+ export interface EmailResponse {
43
+ /**
44
+ * Message ID assigned by the email service
45
+ */
46
+ messageId: string;
47
+ /**
48
+ * Preview URL for test emails (e.g., Ethereal)
49
+ */
50
+ previewUrl?: string;
51
+ }
52
+
53
+ /**
54
+ * Email service implementation interface
55
+ */
56
+ export interface EmailServiceImpl {
57
+ /**
58
+ * Send an email
59
+ */
60
+ sendMail(payload: EmailPayload): Promise<EmailResponse>;
61
+ }