@allbluecn/web-app 0.11.0 → 0.11.1

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 (101) hide show
  1. package/package.json +6 -3
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-dark.png +0 -0
  6. package/public/logo/allblue-logo-light.png +0 -0
  7. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  8. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  9. package/src/components/auth/icon-input.tsx +9 -1
  10. package/src/components/auth/password-input.tsx +9 -1
  11. package/src/components/changelog/changelog-page.tsx +44 -1
  12. package/src/components/forms/app-field.tsx +3 -1
  13. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  14. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  15. package/src/components/notifications/settings-notifications.tsx +41 -1
  16. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  17. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  18. package/src/lib/app-version.ts +1 -1
  19. package/src/lib/auth/auth.config.ts +123 -99
  20. package/src/lib/changelog/sdk-versions.ts +4 -4
  21. package/src/lib/edition-desktop.ts +22 -0
  22. package/src/lib/session.ts +2 -2
  23. package/src/routeTree.gen.ts +114 -0
  24. package/src/routes/__root.tsx +20 -3
  25. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  26. package/src/routes/__tests__/-edition-routing.test.tsx +14 -1
  27. package/src/routes/__tests__/route-tree-structure.test.ts +1 -0
  28. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  29. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  30. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  31. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  32. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  33. package/src/routes/_login/login/route.lazy.tsx +4 -2
  34. package/src/routes/_login/register/route.lazy.tsx +28 -17
  35. package/src/routes/_login/register/route.tsx +8 -1
  36. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  37. package/src/routes/_login/schemas/-auth.ts +30 -7
  38. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  39. package/src/routes/_login/verify-email/route.tsx +28 -0
  40. package/src/routes/_nav/route.tsx +5 -0
  41. package/src/routes/_public/index/route.tsx +2 -1
  42. package/src/routes/api/auth/desktop-callback.ts +2 -2
  43. package/src/routes/api/billing-webhook.ts +4 -0
  44. package/src/routes/api/cron/changelog-release.ts +100 -0
  45. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  46. package/src/routes/api.auth.$.ts +3 -5
  47. package/src/server/__tests__/auth-registration.test.ts +60 -0
  48. package/src/server/auth-registration.ts +40 -0
  49. package/src/server/billing-email.ts +39 -0
  50. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  51. package/src/server/email/__tests__/index.test.ts +32 -0
  52. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  53. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  54. package/src/server/email/__tests__/registry.test.tsx +50 -0
  55. package/src/server/email/deliverability.ts +57 -0
  56. package/src/server/email/drivers/console.ts +11 -0
  57. package/src/server/email/drivers/resend.ts +31 -0
  58. package/src/server/email/drivers/smtp.ts +27 -0
  59. package/src/server/email/index.ts +56 -0
  60. package/src/server/email/outbox.ts +141 -0
  61. package/src/server/email/preferences.ts +20 -0
  62. package/src/server/email/registry.ts +38 -0
  63. package/src/server/email/sender.ts +34 -17
  64. package/src/server/email/templates/_base/button.tsx +12 -0
  65. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  66. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  67. package/src/server/email/templates/_base/theme.ts +22 -0
  68. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  69. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  70. package/src/server/email/templates/auth/welcome.tsx +49 -0
  71. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  72. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  73. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  74. package/src/server/email/templates/notification/mirror.tsx +50 -0
  75. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  76. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  77. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  78. package/src/server/email/templates/team/approved.tsx +53 -0
  79. package/src/server/email/templates/team/invite.tsx +57 -0
  80. package/src/server/middlewares/security-headers.ts +11 -2
  81. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  82. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  83. package/src/server/notifications/notification-service.ts +71 -1
  84. package/src/server/notifications/verify-reminder.ts +55 -0
  85. package/src/server/oauth-config.ts +59 -0
  86. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  87. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  88. package/src/server/serverFns/__tests__/team-invitation.test.ts +2 -2
  89. package/src/server/serverFns/auth/email-verification.ts +132 -0
  90. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  91. package/src/server/serverFns/auth/providers.ts +4 -3
  92. package/src/server/serverFns/auth/register.ts +63 -19
  93. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  94. package/src/server/serverFns/auth/reset-password.ts +1 -1
  95. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  96. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  97. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  98. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  99. package/src/server/serverFns/notifications.ts +10 -0
  100. package/src/server/serverFns/team.ts +49 -11
  101. package/vite.shared.ts +87 -3
@@ -17,9 +17,18 @@
17
17
 
18
18
  import { createMiddleware } from '@tanstack/react-start'
19
19
 
20
+ // ⚠ script-src 必须含 'unsafe-inline':TanStack Start SSR 向 HTML 注入内联
21
+ // dehydration 脚本(window.$_TSR = {...},携带每请求序列化的路由数据)。
22
+ // 不可用 hash 替代——dehydration 内容随每次请求变化,无法预计算;
23
+ // 不可用 nonce 替代——start-server-core 1.168/1.169 无 SSR nonce 自动注入
24
+ // (Router 客户端虽读 meta csp-nonce,服务端无生成方)。若收紧为 'self',
25
+ // hydration 脚本被 CSP 拦截 → $_TSR undefined → 客户端崩溃全站白屏
26
+ // (SSR HTML 与静态资源均正常,仅浏览器端崩溃;Pro 生产 2026-09-11 已复现)。
27
+ // ⚠ va.vercel-scripts.com:Vercel Analytics / Speed Insights 的脚本域名
28
+ // (__root.tsx 的 <Analytics /> / <SpeedInsights />),缺失则脚本被静默阻止。
20
29
  const PROD_CSP = [
21
30
  "default-src 'self'",
22
- "script-src 'self'",
31
+ "script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com",
23
32
  "style-src 'self' 'unsafe-inline'",
24
33
  "img-src 'self' data: blob: https:",
25
34
  "font-src 'self' data:",
@@ -37,7 +46,7 @@ const PROD_CSP = [
37
46
 
38
47
  const DEV_CSP = [
39
48
  "default-src 'self' 'unsafe-inline' data: blob:",
40
- "script-src 'self' 'unsafe-inline' data: blob:",
49
+ "script-src 'self' 'unsafe-inline' data: blob: https://va.vercel-scripts.com",
41
50
  "style-src 'self' 'unsafe-inline'",
42
51
  "img-src 'self' data: blob: https: http:",
43
52
  "font-src 'self' data:",
@@ -0,0 +1,193 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const sendTemplateEmailMock = vi.fn()
5
+ const checkEmailEligibilityMock = vi.fn()
6
+
7
+ vi.mock('@allbluecn/database', () => ({
8
+ prisma: {
9
+ notificationPreference: {
10
+ findUnique: vi.fn(),
11
+ create: vi.fn(),
12
+ },
13
+ notification: {
14
+ create: vi.fn(),
15
+ },
16
+ user: {
17
+ findUnique: vi.fn(),
18
+ },
19
+ },
20
+ Prisma: {},
21
+ }))
22
+
23
+ vi.mock('@/server/notifications/event-bus', () => ({
24
+ notificationBus: {
25
+ emit: vi.fn(),
26
+ subscribe: vi.fn(),
27
+ },
28
+ }))
29
+
30
+ vi.mock('@/server/email/preferences', () => ({
31
+ checkEmailEligibility: (...args: unknown[]) => checkEmailEligibilityMock(...args),
32
+ }))
33
+
34
+ vi.mock('@/server/email', () => ({
35
+ sendTemplateEmail: (...args: unknown[]) => sendTemplateEmailMock(...args),
36
+ }))
37
+
38
+ import { prisma } from '@allbluecn/database'
39
+ import { notificationBus } from '@/server/notifications/event-bus'
40
+ import { createNotification } from '../notification-service'
41
+
42
+ const mockedFindUnique = prisma.notificationPreference.findUnique as ReturnType<typeof vi.fn>
43
+ const mockedNotifCreate = prisma.notification.create as ReturnType<typeof vi.fn>
44
+ const mockedUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>
45
+ const mockedEmit = notificationBus.emit as ReturnType<typeof vi.fn>
46
+
47
+ const createdNotification = {
48
+ id: 'n1',
49
+ category: 'team',
50
+ type: 'invite',
51
+ title: 'New invite',
52
+ body: 'You have been invited',
53
+ link: '/invites/abc',
54
+ readAt: null,
55
+ createdAt: new Date('2026-08-29T10:00:00Z'),
56
+ }
57
+
58
+ const prefsBase = {
59
+ userId: 'u1',
60
+ categories: {
61
+ team: 'realtime',
62
+ collaboration: 'realtime',
63
+ system: 'realtime',
64
+ billing: 'realtime',
65
+ security: 'realtime',
66
+ },
67
+ emailCategories: {
68
+ team: true,
69
+ collaboration: true,
70
+ system: true,
71
+ billing: true,
72
+ security: true,
73
+ },
74
+ dndEnabled: false,
75
+ dndStart: '22:00',
76
+ dndEnd: '08:00',
77
+ }
78
+
79
+ beforeEach(() => {
80
+ vi.clearAllMocks()
81
+ mockedFindUnique.mockResolvedValue(prefsBase)
82
+ mockedNotifCreate.mockResolvedValue(createdNotification)
83
+ checkEmailEligibilityMock.mockResolvedValue({ sendable: true })
84
+ sendTemplateEmailMock.mockResolvedValue({ sent: true })
85
+ })
86
+
87
+ afterEach(() => {
88
+ vi.useRealTimers()
89
+ })
90
+
91
+ async function flushMicrotasks() {
92
+ for (let i = 0; i < 8; i++) await Promise.resolve()
93
+ }
94
+
95
+ describe('createNotification 邮件镜像(六条件链)', () => {
96
+ it('条件 2 失败:emailCategories[cat] 关闭 → 不发送邮件', async () => {
97
+ mockedFindUnique.mockResolvedValue({
98
+ ...prefsBase,
99
+ emailCategories: { ...prefsBase.emailCategories, team: false },
100
+ })
101
+
102
+ await createNotification({
103
+ userId: 'u1',
104
+ category: 'team',
105
+ type: 'invite',
106
+ title: 'New invite',
107
+ })
108
+ await flushMicrotasks()
109
+
110
+ expect(mockedEmit).toHaveBeenCalledTimes(1)
111
+ expect(checkEmailEligibilityMock).not.toHaveBeenCalled()
112
+ expect(mockedUserFindUnique).not.toHaveBeenCalled()
113
+ expect(sendTemplateEmailMock).not.toHaveBeenCalled()
114
+ })
115
+
116
+ it('条件 4 失败:邮箱未验证 → 不发送邮件', async () => {
117
+ mockedUserFindUnique.mockResolvedValue({
118
+ email: 'u1@example.com',
119
+ emailVerified: null,
120
+ locale: 'zh',
121
+ })
122
+
123
+ await createNotification({
124
+ userId: 'u1',
125
+ category: 'team',
126
+ type: 'invite',
127
+ title: 'New invite',
128
+ })
129
+ await flushMicrotasks()
130
+
131
+ expect(mockedUserFindUnique).toHaveBeenCalledTimes(1)
132
+ expect(checkEmailEligibilityMock).not.toHaveBeenCalled()
133
+ expect(sendTemplateEmailMock).not.toHaveBeenCalled()
134
+ })
135
+
136
+ it('条件 5 失败:DND 时段内 → 不发送邮件', async () => {
137
+ // 固定时间为 23:00,DND 22:00-08:00 跨零点区间内
138
+ vi.useFakeTimers()
139
+ vi.setSystemTime(new Date('2026-08-29T23:00:00Z'))
140
+ mockedFindUnique.mockResolvedValue({
141
+ ...prefsBase,
142
+ dndEnabled: true,
143
+ })
144
+ mockedUserFindUnique.mockResolvedValue({
145
+ email: 'u1@example.com',
146
+ emailVerified: new Date(),
147
+ locale: 'zh',
148
+ })
149
+
150
+ await createNotification({
151
+ userId: 'u1',
152
+ category: 'team',
153
+ type: 'invite',
154
+ title: 'New invite',
155
+ })
156
+ await flushMicrotasks()
157
+
158
+ expect(checkEmailEligibilityMock).toHaveBeenCalledTimes(1)
159
+ expect(sendTemplateEmailMock).not.toHaveBeenCalled()
160
+ })
161
+
162
+ it('六条件全开:发送邮件,vars 精确到 title/body/link', async () => {
163
+ mockedUserFindUnique.mockResolvedValue({
164
+ email: 'u1@example.com',
165
+ emailVerified: new Date(),
166
+ locale: 'en',
167
+ })
168
+
169
+ await createNotification({
170
+ userId: 'u1',
171
+ category: 'team',
172
+ type: 'invite',
173
+ title: 'New invite',
174
+ body: 'Body here',
175
+ link: '/invites/abc',
176
+ })
177
+ await flushMicrotasks()
178
+
179
+ expect(checkEmailEligibilityMock).toHaveBeenCalledWith('u1', 'notification')
180
+ expect(sendTemplateEmailMock).toHaveBeenCalledTimes(1)
181
+ expect(sendTemplateEmailMock).toHaveBeenCalledWith({
182
+ key: 'notification.mirror',
183
+ to: 'u1@example.com',
184
+ locale: 'en',
185
+ userId: 'u1',
186
+ vars: {
187
+ title: 'New invite',
188
+ body: 'Body here',
189
+ link: '/invites/abc',
190
+ },
191
+ })
192
+ })
193
+ })
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ vi.mock('@allbluecn/database', () => ({
5
+ prisma: {
6
+ user: {
7
+ findUnique: vi.fn(),
8
+ },
9
+ notification: {
10
+ create: vi.fn(),
11
+ findFirst: vi.fn(),
12
+ },
13
+ },
14
+ Prisma: {},
15
+ }))
16
+
17
+ vi.mock('../notification-service', () => ({
18
+ createNotification: vi.fn(),
19
+ }))
20
+
21
+ import { prisma } from '@allbluecn/database'
22
+ import { createNotification } from '../notification-service'
23
+ import { ensureVerifyReminder } from '../verify-reminder'
24
+
25
+ const mockedUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>
26
+ const mockedNotifFindFirst = prisma.notification.findFirst as ReturnType<typeof vi.fn>
27
+ const mockedCreateNotification = createNotification as ReturnType<typeof vi.fn>
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks()
31
+ })
32
+
33
+ describe('ensureVerifyReminder', () => {
34
+ it('returns early when user does not exist', async () => {
35
+ mockedUserFindUnique.mockResolvedValueOnce(null)
36
+
37
+ await ensureVerifyReminder('missing-user')
38
+
39
+ expect(mockedNotifFindFirst).not.toHaveBeenCalled()
40
+ expect(mockedCreateNotification).not.toHaveBeenCalled()
41
+ })
42
+
43
+ it('returns early when user is already verified', async () => {
44
+ mockedUserFindUnique.mockResolvedValueOnce({ emailVerified: new Date() })
45
+
46
+ await ensureVerifyReminder('u1')
47
+
48
+ expect(mockedNotifFindFirst).not.toHaveBeenCalled()
49
+ expect(mockedCreateNotification).not.toHaveBeenCalled()
50
+ })
51
+
52
+ it('returns early when a reminder already exists for the user', async () => {
53
+ mockedUserFindUnique.mockResolvedValueOnce({ emailVerified: null })
54
+ mockedNotifFindFirst.mockResolvedValueOnce({ id: 'existing-reminder' })
55
+
56
+ await ensureVerifyReminder('u1')
57
+
58
+ expect(mockedNotifFindFirst).toHaveBeenCalledWith({
59
+ where: { userId: 'u1', type: 'email-verify-reminder' },
60
+ select: { id: true },
61
+ })
62
+ expect(mockedCreateNotification).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('creates a reminder notification for unverified users without existing reminder', async () => {
66
+ mockedUserFindUnique.mockResolvedValueOnce({ emailVerified: null })
67
+ mockedNotifFindFirst.mockResolvedValueOnce(null)
68
+ mockedCreateNotification.mockResolvedValueOnce(undefined)
69
+
70
+ await ensureVerifyReminder('u1')
71
+
72
+ expect(mockedCreateNotification).toHaveBeenCalledWith({
73
+ userId: 'u1',
74
+ category: 'system',
75
+ type: 'email-verify-reminder',
76
+ title: '邮箱尚未验证',
77
+ body: '验证邮箱后可开启邮件通知,保障账号安全。可在设置页重新发送验证邮件。',
78
+ link: '/settings/account',
79
+ force: true,
80
+ })
81
+ })
82
+
83
+ it('swallows errors so the caller never sees a rejection', async () => {
84
+ mockedUserFindUnique.mockRejectedValueOnce(new Error('db down'))
85
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
86
+
87
+ await expect(ensureVerifyReminder('u1')).resolves.toBeUndefined()
88
+ expect(spy).toHaveBeenCalledWith(
89
+ 'verify reminder failed',
90
+ expect.objectContaining({ userId: 'u1', reason: 'db down' }),
91
+ )
92
+ spy.mockRestore()
93
+ })
94
+ })
@@ -9,11 +9,21 @@ import type {
9
9
  import {
10
10
  isInDndWindow,
11
11
  normalizeCategoryFrequencies,
12
+ normalizeEmailCategories,
12
13
  DEFAULT_CATEGORIES,
14
+ DEFAULT_EMAIL_CATEGORIES,
13
15
  } from '@allbluecn/kernel'
14
16
  import { notificationBus } from './event-bus'
17
+ import { checkEmailEligibility } from '../email/preferences'
18
+ import { sendTemplateEmail } from '../email'
15
19
 
16
- export { DEFAULT_CATEGORIES, isInDndWindow, normalizeCategoryFrequencies } from '@allbluecn/kernel'
20
+ export {
21
+ DEFAULT_CATEGORIES,
22
+ DEFAULT_EMAIL_CATEGORIES,
23
+ isInDndWindow,
24
+ normalizeCategoryFrequencies,
25
+ normalizeEmailCategories,
26
+ } from '@allbluecn/kernel'
17
27
 
18
28
  export interface CreateNotificationInput {
19
29
  userId: string
@@ -52,6 +62,7 @@ export async function getOrCreatePreferences(userId: string): Promise<Notificati
52
62
  if (existing) {
53
63
  return {
54
64
  categories: normalizeCategoryFrequencies(existing.categories),
65
+ emailCategories: normalizeEmailCategories(existing.emailCategories),
55
66
  dndEnabled: existing.dndEnabled,
56
67
  dndStart: existing.dndStart,
57
68
  dndEnd: existing.dndEnd,
@@ -59,6 +70,7 @@ export async function getOrCreatePreferences(userId: string): Promise<Notificati
59
70
  }
60
71
  const defaults: NotificationPreferences = {
61
72
  categories: { ...DEFAULT_CATEGORIES },
73
+ emailCategories: { ...DEFAULT_EMAIL_CATEGORIES },
62
74
  dndEnabled: false,
63
75
  dndStart: '22:00',
64
76
  dndEnd: '08:00',
@@ -68,6 +80,7 @@ export async function getOrCreatePreferences(userId: string): Promise<Notificati
68
80
  data: {
69
81
  userId,
70
82
  categories: defaults.categories,
83
+ emailCategories: defaults.emailCategories,
71
84
  dndEnabled: defaults.dndEnabled,
72
85
  dndStart: defaults.dndStart,
73
86
  dndEnd: defaults.dndEnd,
@@ -119,12 +132,69 @@ export async function createNotification(input: CreateNotificationInput): Promis
119
132
 
120
133
  if (input.force || frequency === 'realtime') {
121
134
  notificationBus.emit(input.userId, { kind: 'notification', notification: item })
135
+ // 邮件镜像(fire-and-forget,不阻塞站内通知):条件 1-5 在此判定,条件 6(频控)由 outbox 内部执行
136
+ void mirrorToEmail({
137
+ userId: input.userId,
138
+ category: input.category,
139
+ title: input.title,
140
+ body: input.body ?? null,
141
+ link: input.link ?? null,
142
+ })
122
143
  }
123
144
  } catch (e) {
124
145
  console.error('[notifications] create failed:', e instanceof Error ? e.message : 'unknown')
125
146
  }
126
147
  }
127
148
 
149
+ /**
150
+ * 站内通知镜像到邮件(六条件链)。任何失败只记日志不抛出,不阻断站内通知。
151
+ * 条件 1(force/realtime)由调用方判定;条件 2/3/4/5 在此判定;条件 6(频控)由 outbox 内部执行。
152
+ */
153
+ async function mirrorToEmail(input: {
154
+ userId: string
155
+ category: NotificationCategory
156
+ title: string
157
+ body: string | null
158
+ link: string | null
159
+ }): Promise<void> {
160
+ try {
161
+ // 条件 2:邮件渠道开关(通知偏好里该类别的 emailCategories 需开启)
162
+ const prefs = await getOrCreatePreferences(input.userId)
163
+ if (!prefs.emailCategories[input.category]) return
164
+
165
+ // 条件 4:邮箱已验证(User.email 存在且 emailVerified 非空)
166
+ const user = await prisma.user.findUnique({
167
+ where: { id: input.userId },
168
+ select: { email: true, emailVerified: true, locale: true },
169
+ })
170
+ if (!user?.email || !user.emailVerified) return
171
+
172
+ // 条件 3:EmailPreference 未退订/未抑制
173
+ const eligibility = await checkEmailEligibility(input.userId, 'notification')
174
+ if (!eligibility.sendable) return
175
+
176
+ // 条件 5:DND 时段外(复用内核 isInDndWindow,已处理跨零点区间)
177
+ const now = new Date()
178
+ const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
179
+ if (isInDndWindow(prefs, hhmm)) return
180
+
181
+ await sendTemplateEmail({
182
+ key: 'notification.mirror',
183
+ to: user.email,
184
+ locale: user.locale ?? 'zh',
185
+ userId: input.userId,
186
+ vars: {
187
+ title: input.title,
188
+ body: input.body ?? undefined,
189
+ link: input.link ?? undefined,
190
+ },
191
+ })
192
+ } catch (e) {
193
+ // 镜像失败不影响站内通知;仅记录
194
+ console.error('[notifications] mirror to email failed:', e instanceof Error ? e.message : 'unknown')
195
+ }
196
+ }
197
+
128
198
  function formatDigestBody(titles: string[], total: number): string {
129
199
  const lines = titles.slice(0, DIGEST_SUMMARY_ITEMS).map((t) => `· ${t}`)
130
200
  if (total > DIGEST_SUMMARY_ITEMS) {
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { prisma } from '@allbluecn/database'
19
+ import { m } from '@/paraglide/messages.js'
20
+ import { createNotification } from './notification-service'
21
+
22
+ const REMINDER_TYPE = 'email-verify-reminder'
23
+
24
+ export async function ensureVerifyReminder(userId: string): Promise<void> {
25
+ try {
26
+ const user = await prisma.user.findUnique({
27
+ where: { id: userId },
28
+ select: { emailVerified: true, locale: true },
29
+ })
30
+ if (!user || user.emailVerified) return
31
+
32
+ const existing = await prisma.notification.findFirst({
33
+ where: { userId, type: REMINDER_TYPE },
34
+ select: { id: true },
35
+ })
36
+ if (existing) return
37
+
38
+ const msgOptions = user.locale === 'en' ? { locale: 'en' as const } : undefined
39
+
40
+ await createNotification({
41
+ userId,
42
+ category: 'system',
43
+ type: REMINDER_TYPE,
44
+ title: m.notifications_verify_emailReminderTitle(undefined, msgOptions),
45
+ body: m.notifications_verify_emailReminderBody(undefined, msgOptions),
46
+ link: '/settings/account',
47
+ force: true,
48
+ })
49
+ } catch (err) {
50
+ console.error('verify reminder failed', {
51
+ userId,
52
+ reason: err instanceof Error ? err.message : String(err),
53
+ })
54
+ }
55
+ }
@@ -0,0 +1,59 @@
1
+ // ⚠ 仅服务端 import。依赖 prisma + node:crypto,禁止在客户端组件/路由文件顶层引用。
2
+ //
3
+ // 设计:
4
+ // - 60s 模块级缓存(CACHE_TTL_MS):admin 后台保存后 web 端最多 60s 生效,符合计划 spec。
5
+ // - 数据源:DB(OAuthProviderConfig)优先;DB 无 google 记录时回落 env(AUTH_GOOGLE_ID/SECRET)。
6
+ // - 缓存失效:invalidateOAuthConfigCache 为跨 app 场景预留(当前无调用方;跨 app 通过 TTL 自然过期)。
7
+ // - clientSecret 存 AES-256-GCM 密文(见 crypto.ts),运行时解密。
8
+ import { prisma } from '@allbluecn/database'
9
+ import { decryptSecret } from '@allbluecn/kernel/crypto'
10
+
11
+ export interface ResolvedOAuthProvider {
12
+ provider: 'google' | (string & {})
13
+ clientId: string
14
+ clientSecret: string
15
+ }
16
+
17
+ const CACHE_TTL_MS = 60_000
18
+ let cache: { at: number; providers: ResolvedOAuthProvider[] } | null = null
19
+
20
+ export async function getResolvedOAuthProviders(): Promise<ResolvedOAuthProvider[]> {
21
+ if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.providers
22
+ const rows = await prisma.oAuthProviderConfig.findMany({
23
+ where: { enabled: true },
24
+ select: { provider: true, clientId: true, clientSecret: true },
25
+ }).catch((e) => {
26
+ console.error('[oauth-config] DB 读取失败,回落 env:', e instanceof Error ? e.message : String(e))
27
+ return []
28
+ })
29
+ const providers: ResolvedOAuthProvider[] = rows
30
+ .map((r) => {
31
+ const clientSecret = decryptSecret(r.clientSecret)
32
+ if (!clientSecret) {
33
+ console.warn(`[oauth-config] provider="${r.provider}" clientSecret 解密失败或为空,跳过`)
34
+ }
35
+ return {
36
+ provider: r.provider,
37
+ clientId: r.clientId,
38
+ clientSecret: clientSecret ?? '',
39
+ }
40
+ })
41
+ .filter((r) => r.clientId && r.clientSecret)
42
+ if (
43
+ !providers.some((p) => p.provider === 'google') &&
44
+ process.env.AUTH_GOOGLE_ID &&
45
+ process.env.AUTH_GOOGLE_SECRET
46
+ ) {
47
+ providers.push({
48
+ provider: 'google',
49
+ clientId: process.env.AUTH_GOOGLE_ID,
50
+ clientSecret: process.env.AUTH_GOOGLE_SECRET,
51
+ })
52
+ }
53
+ cache = { at: Date.now(), providers }
54
+ return providers
55
+ }
56
+
57
+ export function invalidateOAuthConfigCache() {
58
+ cache = null
59
+ }