@lobehub/lobehub 2.0.0-next.124 → 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.
- package/.cursor/rules/db-migrations.mdc +16 -1
- package/.cursor/rules/project-introduce.mdc +1 -1
- package/.cursor/rules/project-structure.mdc +20 -2
- package/.env.example +148 -65
- package/.env.example.development +6 -8
- package/AGENTS.md +1 -3
- package/CHANGELOG.md +25 -0
- package/Dockerfile +6 -6
- package/GEMINI.md +63 -0
- package/changelog/v1.json +9 -0
- package/docs/development/database-schema.dbml +37 -0
- package/docs/self-hosting/advanced/auth.mdx +75 -2
- package/docs/self-hosting/advanced/auth.zh-CN.mdx +75 -2
- package/docs/self-hosting/environment-variables/auth.mdx +187 -1
- package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
- package/locales/en-US/auth.json +93 -0
- package/locales/zh-CN/auth.json +107 -1
- package/package.json +5 -2
- package/packages/const/src/auth.ts +2 -1
- package/packages/database/migrations/0049_better_auth.sql +49 -0
- package/packages/database/migrations/meta/0048_snapshot.json +312 -932
- package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
- package/packages/database/migrations/meta/_journal.json +8 -1
- package/packages/database/src/core/migrations.json +13 -0
- package/packages/database/src/index.ts +1 -0
- package/packages/database/src/models/__tests__/session.test.ts +1 -2
- package/packages/database/src/models/user.ts +9 -8
- package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
- package/packages/database/src/schemas/betterAuth.ts +63 -0
- package/packages/database/src/schemas/index.ts +1 -0
- package/packages/database/src/schemas/ragEvals.ts +1 -2
- package/packages/database/src/schemas/user.ts +3 -2
- package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
- package/packages/types/src/user/preference.ts +11 -0
- package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
- package/packages/utils/src/server/auth.ts +18 -1
- package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
- package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
- package/src/app/(backend)/middleware/auth/index.ts +14 -0
- package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
- package/src/app/(backend)/middleware/auth/utils.ts +13 -10
- package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
- package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
- package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
- package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
- package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
- package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
- package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
- package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
- package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
- package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
- package/src/auth.ts +118 -0
- package/src/components/NextAuth/AuthIcons.tsx +3 -1
- package/src/envs/auth.ts +260 -13
- package/src/envs/email.ts +37 -0
- package/src/features/User/UserPanel/PanelContent.tsx +6 -5
- package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
- package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
- package/src/features/User/__tests__/useMenu.test.tsx +14 -12
- package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
- package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
- package/src/layout/AuthProvider/index.tsx +3 -0
- package/src/libs/better-auth/auth-client.ts +34 -0
- package/src/libs/better-auth/constants.ts +13 -0
- package/src/libs/better-auth/email-templates/index.ts +3 -0
- package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
- package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
- package/src/libs/better-auth/email-templates/verification.ts +108 -0
- package/src/libs/better-auth/sso/helpers.ts +61 -0
- package/src/libs/better-auth/sso/index.ts +113 -0
- package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
- package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
- package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
- package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
- package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
- package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
- package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
- package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
- package/src/libs/better-auth/sso/providers/github.ts +30 -0
- package/src/libs/better-auth/sso/providers/google.ts +30 -0
- package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
- package/src/libs/better-auth/sso/providers/logto.ts +38 -0
- package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
- package/src/libs/better-auth/sso/providers/okta.ts +37 -0
- package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
- package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
- package/src/libs/better-auth/sso/types.ts +25 -0
- package/src/libs/better-auth/utils/client.ts +1 -0
- package/src/libs/better-auth/utils/common.ts +20 -0
- package/src/libs/better-auth/utils/server.test.ts +61 -0
- package/src/libs/better-auth/utils/server.ts +18 -0
- package/src/libs/trpc/lambda/context.test.ts +116 -0
- package/src/libs/trpc/lambda/context.ts +27 -0
- package/src/libs/trpc/middleware/userAuth.ts +4 -2
- package/src/locales/default/auth.ts +114 -1
- package/src/proxy.ts +71 -7
- package/src/server/globalConfig/index.ts +12 -1
- package/src/server/routers/lambda/user.ts +4 -0
- package/src/server/services/email/README.md +241 -0
- package/src/server/services/email/impls/index.test.ts +39 -0
- package/src/server/services/email/impls/index.ts +32 -0
- package/src/server/services/email/impls/nodemailer/index.ts +108 -0
- package/src/server/services/email/impls/nodemailer/type.ts +31 -0
- package/src/server/services/email/impls/type.ts +61 -0
- package/src/server/services/email/index.test.ts +144 -0
- package/src/server/services/email/index.ts +40 -0
- package/src/services/user/index.test.ts +162 -2
- package/src/services/user/index.ts +6 -3
- package/src/store/user/slices/auth/action.test.ts +213 -16
- package/src/store/user/slices/auth/action.ts +86 -1
- package/src/store/user/slices/auth/initialState.ts +13 -2
- package/src/store/user/slices/auth/selectors.ts +6 -2
- package/src/store/user/slices/common/action.ts +5 -1
- package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { EmailImplType, createEmailServiceImpl } from './impls';
|
|
4
|
+
import { EmailService } from './index';
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
vi.mock('./impls');
|
|
8
|
+
|
|
9
|
+
describe('EmailService', () => {
|
|
10
|
+
let emailService: EmailService;
|
|
11
|
+
let mockEmailImpl: ReturnType<typeof createMockEmailImpl>;
|
|
12
|
+
|
|
13
|
+
function createMockEmailImpl() {
|
|
14
|
+
return {
|
|
15
|
+
sendMail: vi.fn(),
|
|
16
|
+
verify: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
mockEmailImpl = createMockEmailImpl();
|
|
23
|
+
vi.mocked(createEmailServiceImpl).mockReturnValue(mockEmailImpl as any);
|
|
24
|
+
emailService = new EmailService();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('constructor', () => {
|
|
28
|
+
it('should create instance with default email implementation', () => {
|
|
29
|
+
expect(createEmailServiceImpl).toHaveBeenCalledWith(undefined);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should create instance with specified implementation type', () => {
|
|
33
|
+
emailService = new EmailService(EmailImplType.Nodemailer);
|
|
34
|
+
expect(createEmailServiceImpl).toHaveBeenCalledWith(EmailImplType.Nodemailer);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('sendMail', () => {
|
|
39
|
+
it('should call emailImpl.sendMail with correct payload', async () => {
|
|
40
|
+
const mockResponse = {
|
|
41
|
+
messageId: 'test-message-id',
|
|
42
|
+
previewUrl: 'https://ethereal.email/message/xxx',
|
|
43
|
+
};
|
|
44
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
45
|
+
|
|
46
|
+
const payload = {
|
|
47
|
+
from: 'sender@example.com',
|
|
48
|
+
html: '<p>Hello world</p>',
|
|
49
|
+
subject: 'Test Email',
|
|
50
|
+
text: 'Hello world',
|
|
51
|
+
to: 'recipient@example.com',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = await emailService.sendMail(payload);
|
|
55
|
+
|
|
56
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
57
|
+
expect(result).toBe(mockResponse);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should support multiple recipients', async () => {
|
|
61
|
+
const mockResponse = {
|
|
62
|
+
messageId: 'test-message-id',
|
|
63
|
+
};
|
|
64
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
65
|
+
|
|
66
|
+
const payload = {
|
|
67
|
+
from: 'sender@example.com',
|
|
68
|
+
subject: 'Test Email',
|
|
69
|
+
text: 'Hello world',
|
|
70
|
+
to: ['recipient1@example.com', 'recipient2@example.com'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await emailService.sendMail(payload);
|
|
74
|
+
|
|
75
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should support attachments', async () => {
|
|
79
|
+
const mockResponse = {
|
|
80
|
+
messageId: 'test-message-id',
|
|
81
|
+
};
|
|
82
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
83
|
+
|
|
84
|
+
const payload = {
|
|
85
|
+
attachments: [
|
|
86
|
+
{
|
|
87
|
+
content: Buffer.from('test content'),
|
|
88
|
+
filename: 'test.txt',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
from: 'sender@example.com',
|
|
92
|
+
subject: 'Test Email',
|
|
93
|
+
text: 'Hello world',
|
|
94
|
+
to: 'recipient@example.com',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
await emailService.sendMail(payload);
|
|
98
|
+
|
|
99
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should support reply-to address', async () => {
|
|
103
|
+
const mockResponse = {
|
|
104
|
+
messageId: 'test-message-id',
|
|
105
|
+
};
|
|
106
|
+
mockEmailImpl.sendMail.mockResolvedValue(mockResponse);
|
|
107
|
+
|
|
108
|
+
const payload = {
|
|
109
|
+
from: 'noreply@example.com',
|
|
110
|
+
replyTo: 'support@example.com',
|
|
111
|
+
subject: 'Test Email',
|
|
112
|
+
text: 'Hello world',
|
|
113
|
+
to: 'recipient@example.com',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
await emailService.sendMail(payload);
|
|
117
|
+
|
|
118
|
+
expect(mockEmailImpl.sendMail).toHaveBeenCalledWith(payload);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('verify', () => {
|
|
123
|
+
it('should call emailImpl.verify if available', async () => {
|
|
124
|
+
mockEmailImpl.verify.mockResolvedValue(true);
|
|
125
|
+
|
|
126
|
+
const result = await emailService.verify();
|
|
127
|
+
|
|
128
|
+
expect(mockEmailImpl.verify).toHaveBeenCalled();
|
|
129
|
+
expect(result).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should return true if verify method is not available', async () => {
|
|
133
|
+
const mockImplWithoutVerify = {
|
|
134
|
+
sendMail: vi.fn(),
|
|
135
|
+
};
|
|
136
|
+
vi.mocked(createEmailServiceImpl).mockReturnValue(mockImplWithoutVerify as any);
|
|
137
|
+
emailService = new EmailService();
|
|
138
|
+
|
|
139
|
+
const result = await emailService.verify();
|
|
140
|
+
|
|
141
|
+
expect(result).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
import { EmailImplType, EmailPayload, EmailResponse, createEmailServiceImpl } from './impls';
|
|
3
|
+
import type { EmailServiceImpl } from './impls';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Email service class
|
|
7
|
+
* Provides email sending functionality with multiple provider support
|
|
8
|
+
*/
|
|
9
|
+
export class EmailService {
|
|
10
|
+
private emailImpl: EmailServiceImpl;
|
|
11
|
+
|
|
12
|
+
constructor(implType?: EmailImplType) {
|
|
13
|
+
this.emailImpl = createEmailServiceImpl(implType);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Send an email
|
|
18
|
+
*/
|
|
19
|
+
async sendMail(payload: EmailPayload): Promise<EmailResponse> {
|
|
20
|
+
return this.emailImpl.sendMail(payload);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Verify the email service configuration
|
|
25
|
+
* Note: Only available for Nodemailer implementation
|
|
26
|
+
*/
|
|
27
|
+
async verify(): Promise<boolean> {
|
|
28
|
+
// Check if the implementation has a verify method
|
|
29
|
+
if ('verify' in this.emailImpl && typeof this.emailImpl.verify === 'function') {
|
|
30
|
+
return this.emailImpl.verify();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// For implementations without verify, assume it's valid
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Export types
|
|
39
|
+
export type { EmailPayload, EmailResponse } from './impls';
|
|
40
|
+
export { EmailImplType } from './impls';
|