@lobehub/lobehub 2.0.0-next.144 → 2.0.0-next.146
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/project-introduce.mdc +1 -1
- package/.github/workflows/test.yml +3 -0
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +50 -0
- package/apps/desktop/package.json +2 -0
- package/apps/desktop/src/main/controllers/__tests__/UploadFileCtr.test.ts +8 -12
- package/apps/desktop/src/main/core/infrastructure/__tests__/ProtocolManager.test.ts +1 -0
- package/apps/desktop/src/main/core/ui/__tests__/Tray.test.ts +2 -2
- package/apps/desktop/src/main/utils/__tests__/file-system.test.ts +1 -1
- package/apps/desktop/src/main/utils/__tests__/logger.test.ts +7 -7
- package/apps/desktop/src/main/utils/next-electron-rsc.ts +3 -1
- package/apps/desktop/src/preload/invoke.test.ts +4 -2
- package/apps/desktop/src/preload/routeInterceptor.test.ts +54 -9
- package/apps/desktop/src/preload/streamer.test.ts +32 -31
- package/changelog/v1.json +18 -0
- package/docs/development/database-schema.dbml +2 -1
- package/docs/self-hosting/advanced/auth.mdx +21 -10
- package/docs/self-hosting/advanced/auth.zh-CN.mdx +21 -10
- package/package.json +4 -3
- package/packages/database/migrations/0056_update_agent_slug_index.sql +2 -0
- package/packages/database/migrations/meta/0056_snapshot.json +8411 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/core/migrations.json +11 -2
- package/packages/database/src/schemas/agent.ts +2 -3
- package/packages/electron-client-ipc/src/events/system.ts +1 -3
- package/packages/electron-client-ipc/src/types/system.ts +1 -0
- package/src/envs/email.ts +11 -0
- package/src/libs/better-auth/email-templates/magic-link.ts +5 -5
- package/src/libs/better-auth/email-templates/reset-password.ts +4 -4
- package/src/libs/better-auth/email-templates/verification.ts +4 -4
- package/src/libs/mcp/__tests__/__snapshots__/index.test.ts.snap +9 -0
- package/src/server/services/email/README.md +19 -0
- package/src/server/services/email/impls/index.ts +5 -1
- package/src/server/services/email/impls/resend/index.ts +120 -0
- package/src/server/services/email/index.test.ts +1 -1
- package/src/server/services/email/index.ts +9 -1
- package/src/server/services/file/impls/index.ts +3 -3
- package/src/server/services/file/impls/local.ts +35 -35
- package/src/server/services/file/impls/s3.ts +1 -1
- package/src/server/services/file/impls/type.ts +11 -11
- package/src/server/services/file/index.ts +12 -12
|
@@ -392,6 +392,13 @@
|
|
|
392
392
|
"when": 1764583392443,
|
|
393
393
|
"tag": "0055_rename_phone_number_to_phone",
|
|
394
394
|
"breakpoints": true
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"idx": 56,
|
|
398
|
+
"version": "7",
|
|
399
|
+
"when": 1764685643024,
|
|
400
|
+
"tag": "0056_update_agent_slug_index",
|
|
401
|
+
"breakpoints": true
|
|
395
402
|
}
|
|
396
403
|
],
|
|
397
404
|
"version": "6"
|
|
@@ -884,12 +884,12 @@
|
|
|
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 UPDATE \"users\" SET \"email\" = NULL WHERE \"email\" = '';\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 -- Normalize empty emails so the unique constraint can be created safely\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,
|
|
891
891
|
"folderMillis": 1764579351312,
|
|
892
|
-
"hash": "
|
|
892
|
+
"hash": "1d2536a9471bb87686b35053f98ba7762259a07c819dc4489bb4f3c7f27a4d8d"
|
|
893
893
|
},
|
|
894
894
|
{
|
|
895
895
|
"sql": [
|
|
@@ -901,5 +901,14 @@
|
|
|
901
901
|
"bps": true,
|
|
902
902
|
"folderMillis": 1764583392443,
|
|
903
903
|
"hash": "6a43d90ee1d2e1e008d1b8206f227aa05b9a2e2a8fc8c31ec608a3716c716a6c"
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
"sql": [
|
|
907
|
+
"ALTER TABLE \"agents\" DROP CONSTRAINT \"agents_slug_unique\";",
|
|
908
|
+
"\nCREATE UNIQUE INDEX IF NOT EXISTS \"agents_slug_user_id_unique\" ON \"agents\" USING btree (\"slug\",\"user_id\");\n"
|
|
909
|
+
],
|
|
910
|
+
"bps": true,
|
|
911
|
+
"folderMillis": 1764685643024,
|
|
912
|
+
"hash": "6e7ac7f964eb03efa3cb0d2fd35ded23e25c3abf955c4c2a51418f8daef54af9"
|
|
904
913
|
}
|
|
905
914
|
]
|
|
@@ -28,9 +28,7 @@ export const agents = pgTable(
|
|
|
28
28
|
.primaryKey()
|
|
29
29
|
.$defaultFn(() => idGenerator('agents'))
|
|
30
30
|
.notNull(),
|
|
31
|
-
slug: varchar('slug', { length: 100 })
|
|
32
|
-
.$defaultFn(() => randomSlug(4))
|
|
33
|
-
.unique(),
|
|
31
|
+
slug: varchar('slug', { length: 100 }).$defaultFn(() => randomSlug(3)),
|
|
34
32
|
title: varchar('title', { length: 255 }),
|
|
35
33
|
description: varchar('description', { length: 1000 }),
|
|
36
34
|
tags: jsonb('tags').$type<string[]>().default([]),
|
|
@@ -65,6 +63,7 @@ export const agents = pgTable(
|
|
|
65
63
|
},
|
|
66
64
|
(t) => [
|
|
67
65
|
uniqueIndex('client_id_user_id_unique').on(t.clientId, t.userId),
|
|
66
|
+
uniqueIndex('agents_slug_user_id_unique').on(t.slug, t.userId),
|
|
68
67
|
index('agents_title_idx').on(t.title),
|
|
69
68
|
index('agents_description_idx').on(t.description),
|
|
70
69
|
],
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { ThemeAppearance } from '
|
|
2
|
-
|
|
3
|
-
import { ElectronAppState, ThemeMode } from '../types';
|
|
1
|
+
import { ElectronAppState, ThemeAppearance, ThemeMode } from '../types';
|
|
4
2
|
|
|
5
3
|
export interface SystemDispatchEvents {
|
|
6
4
|
checkSystemAccessibility: () => boolean | undefined;
|
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
|
|
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;">
|
|
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
|
|
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()}
|
|
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
|
|
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;">
|
|
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
|
|
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()}
|
|
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 -
|
|
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;">
|
|
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
|
|
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
|
|
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 -
|
|
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
|
};
|
|
@@ -4,6 +4,9 @@ exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
|
|
|
4
4
|
[
|
|
5
5
|
{
|
|
6
6
|
"description": "Echoes back a message with 'Hello' prefix",
|
|
7
|
+
"execution": {
|
|
8
|
+
"taskSupport": "forbidden",
|
|
9
|
+
},
|
|
7
10
|
"inputSchema": {
|
|
8
11
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
9
12
|
"additionalProperties": false,
|
|
@@ -22,6 +25,9 @@ exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
|
|
|
22
25
|
},
|
|
23
26
|
{
|
|
24
27
|
"description": "Lists all available tools and methods",
|
|
28
|
+
"execution": {
|
|
29
|
+
"taskSupport": "forbidden",
|
|
30
|
+
},
|
|
25
31
|
"inputSchema": {
|
|
26
32
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
27
33
|
"properties": {},
|
|
@@ -31,6 +37,9 @@ exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
|
|
|
31
37
|
},
|
|
32
38
|
{
|
|
33
39
|
"description": "Adds two numbers",
|
|
40
|
+
"execution": {
|
|
41
|
+
"taskSupport": "forbidden",
|
|
42
|
+
},
|
|
34
43
|
"inputSchema": {
|
|
35
44
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
36
45
|
"additionalProperties": false,
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
}
|