@allbluecn/web-app 0.10.3 → 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 (246) hide show
  1. package/package.json +24 -11
  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-app-icon.svg +7 -0
  6. package/public/logo/allblue-logo-dark-square.svg +4 -0
  7. package/public/logo/allblue-logo-dark.png +0 -0
  8. package/public/logo/allblue-logo-dark.svg +4 -0
  9. package/public/logo/allblue-logo-light-square.svg +4 -0
  10. package/public/logo/allblue-logo-light.png +0 -0
  11. package/public/logo/allblue-logo-light.svg +4 -0
  12. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  13. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  14. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  15. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  16. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  17. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  18. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  19. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  20. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  21. package/src/__tests__/setup.ts +9 -0
  22. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  23. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  24. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  25. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  26. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  27. package/src/components/auth/icon-input.tsx +9 -1
  28. package/src/components/auth/password-input.tsx +9 -1
  29. package/src/components/changelog/changelog-page.tsx +44 -1
  30. package/src/components/error/global-error-boundary.tsx +2 -2
  31. package/src/components/forms/app-field.tsx +3 -1
  32. package/src/components/knowledge/general-groups.ts +15 -0
  33. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  34. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  35. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  36. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  37. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  38. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  39. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  40. package/src/components/notifications/settings-notifications.tsx +41 -1
  41. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  42. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  43. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  44. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  45. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  46. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  47. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  48. package/src/components/story/relations/general-groups.ts +1 -15
  49. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  50. package/src/components/story/story-list-view.tsx +7 -1
  51. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  52. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  53. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  54. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  55. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  56. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  57. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  58. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  59. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  60. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  61. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  62. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  63. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  64. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  65. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  66. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  67. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  68. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  69. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  70. package/src/lib/__tests__/pgvector.test.ts +2 -2
  71. package/src/lib/app-version.ts +1 -1
  72. package/src/lib/auth/auth.config.ts +123 -99
  73. package/src/lib/changelog/sdk-versions.ts +21 -21
  74. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  75. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  76. package/src/lib/edition-desktop.ts +22 -0
  77. package/src/lib/fetch-json.ts +23 -0
  78. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  79. package/src/lib/sanitize.ts +14 -1
  80. package/src/lib/session.ts +10 -2
  81. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  82. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  83. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  84. package/src/routeTree.gen.ts +114 -0
  85. package/src/routes/__root.tsx +20 -3
  86. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  87. package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
  88. package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
  89. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  90. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  91. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  92. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  93. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  94. package/src/routes/_login/login/route.lazy.tsx +4 -2
  95. package/src/routes/_login/register/route.lazy.tsx +28 -17
  96. package/src/routes/_login/register/route.tsx +8 -1
  97. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  98. package/src/routes/_login/schemas/-auth.ts +30 -7
  99. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  100. package/src/routes/_login/verify-email/route.tsx +28 -0
  101. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  102. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  103. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  104. package/src/routes/_nav/route.tsx +26 -0
  105. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  106. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  107. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  108. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  109. package/src/routes/_public/index/route.tsx +2 -1
  110. package/src/routes/api/auth/desktop-callback.ts +2 -2
  111. package/src/routes/api/billing-webhook.ts +4 -0
  112. package/src/routes/api/cron/changelog-release.ts +100 -0
  113. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  114. package/src/routes/api.auth.$.ts +3 -5
  115. package/src/server/__tests__/agent-crud.test.ts +4 -4
  116. package/src/server/__tests__/auth-registration.test.ts +60 -0
  117. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  118. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  119. package/src/server/__tests__/permissions.test.ts +1 -3
  120. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  121. package/src/server/__tests__/skill-crud.test.ts +3 -3
  122. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  123. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  124. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  125. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  126. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  127. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  128. package/src/server/ai-gateway/index.ts +5 -0
  129. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  130. package/src/server/ai-gateway/llm-fence.ts +82 -0
  131. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  132. package/src/server/ai-gateway/story-prompt.ts +4 -2
  133. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  134. package/src/server/ai-gateway/tools/server.ts +97 -54
  135. package/src/server/auth-registration.ts +40 -0
  136. package/src/server/billing-email.ts +39 -0
  137. package/src/server/builtin-project.ts +21 -0
  138. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  139. package/src/server/email/__tests__/index.test.ts +32 -0
  140. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  141. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  142. package/src/server/email/__tests__/registry.test.tsx +50 -0
  143. package/src/server/email/deliverability.ts +57 -0
  144. package/src/server/email/drivers/console.ts +11 -0
  145. package/src/server/email/drivers/resend.ts +31 -0
  146. package/src/server/email/drivers/smtp.ts +27 -0
  147. package/src/server/email/index.ts +56 -0
  148. package/src/server/email/outbox.ts +141 -0
  149. package/src/server/email/preferences.ts +20 -0
  150. package/src/server/email/registry.ts +38 -0
  151. package/src/server/email/sender.ts +36 -13
  152. package/src/server/email/templates/_base/button.tsx +12 -0
  153. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  154. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  155. package/src/server/email/templates/_base/theme.ts +22 -0
  156. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  157. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  158. package/src/server/email/templates/auth/welcome.tsx +49 -0
  159. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  160. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  161. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  162. package/src/server/email/templates/notification/mirror.tsx +50 -0
  163. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  164. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  165. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  166. package/src/server/email/templates/team/approved.tsx +53 -0
  167. package/src/server/email/templates/team/invite.tsx +57 -0
  168. package/src/server/fetch-guard.ts +170 -0
  169. package/src/server/middlewares/security-headers.ts +122 -0
  170. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  171. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  172. package/src/server/notifications/notification-service.ts +71 -1
  173. package/src/server/notifications/verify-reminder.ts +55 -0
  174. package/src/server/oauth-config.ts +59 -0
  175. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  176. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  177. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  178. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  179. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  180. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  181. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  182. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  183. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  184. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  185. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  186. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  187. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  188. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  189. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  190. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  191. package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
  192. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  193. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  194. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  195. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  196. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  197. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  198. package/src/server/serverFns/auth/email-verification.ts +132 -0
  199. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  200. package/src/server/serverFns/auth/providers.ts +4 -3
  201. package/src/server/serverFns/auth/register.ts +63 -19
  202. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  203. package/src/server/serverFns/auth/reset-password.ts +1 -1
  204. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  205. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  206. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  207. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  208. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  209. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  210. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  211. package/src/server/serverFns/kb-doc-search.ts +111 -0
  212. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  213. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  214. package/src/server/serverFns/notifications.ts +10 -0
  215. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  216. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  217. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  218. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  219. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  220. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  221. package/src/server/serverFns/story/kb-general.ts +30 -17
  222. package/src/server/serverFns/story/story-assoc.ts +13 -103
  223. package/src/server/serverFns/story/story-crud.ts +2 -5
  224. package/src/server/serverFns/story/story-groups.ts +4 -13
  225. package/src/server/serverFns/team.ts +50 -12
  226. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  227. package/src/server/serverFns/workspace-kb.ts +403 -0
  228. package/src/server/serverFns/workspace.ts +4 -1
  229. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  230. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  231. package/src/shared/ai-cli/adapter.ts +2 -2
  232. package/src/shared/ai-cli/cli-detector.ts +18 -9
  233. package/src/start.ts +2 -1
  234. package/src/styles/globals.css +114 -0
  235. package/src/types/jest-dom-vitest.d.ts +8 -7
  236. package/vite.shared.ts +105 -4
  237. package/public/file.svg +0 -1
  238. package/public/globe.svg +0 -1
  239. package/public/window.svg +0 -1
  240. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  241. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  242. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  243. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  244. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  245. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  246. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { signUnsubscribeToken, verifyUnsubscribeToken, buildUnsubscribeHeaders } from '../deliverability'
3
+
4
+ describe('unsubscribe HMAC token', () => {
5
+ it('签名后可验签还原 payload', () => {
6
+ const token = signUnsubscribeToken({ userId: 'u1', email: 'a@b.com', category: 'notification' })
7
+ const payload = verifyUnsubscribeToken(token)
8
+ expect(payload).toMatchObject({ userId: 'u1', email: 'a@b.com', category: 'notification' })
9
+ })
10
+
11
+ it('篡改 token 验签失败返回 null', () => {
12
+ const token = signUnsubscribeToken({ userId: 'u1', email: 'a@b.com', category: 'notification' })
13
+ expect(verifyUnsubscribeToken(token.slice(0, -2) + 'xx')).toBeNull()
14
+ })
15
+
16
+ it('过期 token(exp 过去)返回 null', () => {
17
+ const token = signUnsubscribeToken({ userId: 'u1', email: 'a@b.com', category: 'notification' }, -1000)
18
+ expect(verifyUnsubscribeToken(token)).toBeNull()
19
+ })
20
+
21
+ it('buildUnsubscribeHeaders 输出标准 List-Unsubscribe 双通道与 One-Click 声明', () => {
22
+ const headers = buildUnsubscribeHeaders({
23
+ appUrl: 'http://localhost:3000',
24
+ userId: 'u1',
25
+ email: 'a@b.com',
26
+ category: 'notification',
27
+ })
28
+ expect(headers['List-Unsubscribe']).toContain('<mailto:unsubscribe@allblue.local?subject=unsubscribe>')
29
+ expect(headers['List-Unsubscribe']).toContain('/email-unsubscribe?token=')
30
+ expect(headers['List-Unsubscribe'].trim().startsWith('<')).toBe(true)
31
+ expect(headers['List-Unsubscribe-Post']).toBe('One-Click=List-Unsubscribe')
32
+ })
33
+
34
+ it('空 token / 无分隔符 / 非法 base64 均返回 null', () => {
35
+ expect(verifyUnsubscribeToken('')).toBeNull()
36
+ expect(verifyUnsubscribeToken('nodot')).toBeNull()
37
+ expect(verifyUnsubscribeToken('.')).toBeNull()
38
+ const valid = signUnsubscribeToken({ userId: 'u1', email: 'a@b.com', category: 'notification' })
39
+ const [body] = valid.split('.')
40
+ expect(verifyUnsubscribeToken(`${body}.${'!!!'}`)).toBeNull()
41
+ })
42
+
43
+ it('过期 token 边界:exp 等于当前时间仍有效', () => {
44
+ const now = signUnsubscribeToken({ userId: 'u1', email: 'a@b.com', category: 'notification' }, 1000)
45
+ expect(verifyUnsubscribeToken(now)).not.toBeNull()
46
+ })
47
+
48
+ it('篡改 body 后验签失败', () => {
49
+ const valid = signUnsubscribeToken({ userId: 'u1', email: 'a@b.com', category: 'notification' })
50
+ const [, sig] = valid.split('.')
51
+ const tampered = Buffer.from(
52
+ JSON.stringify({ userId: 'attacker', email: 'a@b.com', category: 'notification', exp: Date.now() + 1000 }),
53
+ 'utf8',
54
+ ).toString('base64url')
55
+ expect(verifyUnsubscribeToken(`${tampered}.${sig}`)).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe('unsubscribe HMAC token - key isolation', () => {
60
+ afterEach(() => vi.unstubAllEnvs())
61
+
62
+ it('不同密钥签名的 token 无法互验', () => {
63
+ vi.stubEnv('AUTH_SECRET', 'secret-a')
64
+ const token = signUnsubscribeToken({ userId: 'u1', email: 'a@b.com', category: 'notification' })
65
+ vi.stubEnv('AUTH_SECRET', 'secret-b')
66
+ expect(verifyUnsubscribeToken(token)).toBeNull()
67
+ })
68
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../outbox', () => ({
4
+ outboxSend: vi.fn().mockResolvedValue({ sent: true }),
5
+ generateEmailToken: vi.fn().mockReturnValue('tok-123'),
6
+ }))
7
+
8
+ import { sendTemplateEmail, generateEmailToken } from '../index'
9
+ import { outboxSend } from '../outbox'
10
+
11
+ beforeEach(() => {
12
+ vi.clearAllMocks()
13
+ })
14
+
15
+ describe('sendTemplateEmail', () => {
16
+ it('locale=en 归一为 en', async () => {
17
+ await sendTemplateEmail({ key: 'auth.welcome', to: 'a@b.com', locale: 'en', vars: {} })
18
+ expect(outboxSend).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en' }))
19
+ })
20
+
21
+ it('locale=zh / undefined / 未知语言均归一为 zh', async () => {
22
+ for (const locale of ['zh', undefined, 'fr'] as const) {
23
+ vi.clearAllMocks()
24
+ await sendTemplateEmail({ key: 'auth.welcome', to: 'a@b.com', locale, vars: {} })
25
+ expect(outboxSend).toHaveBeenCalledWith(expect.objectContaining({ locale: 'zh' }))
26
+ }
27
+ })
28
+
29
+ it('generateEmailToken 透传 outbox 实现', () => {
30
+ expect(generateEmailToken()).toBe('tok-123')
31
+ })
32
+ })
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@allbluecn/database', () => ({
4
+ prisma: {
5
+ emailLog: {
6
+ create: vi.fn().mockResolvedValue({ id: 'log1' }),
7
+ update: vi.fn().mockResolvedValue({}),
8
+ count: vi.fn().mockResolvedValue(0),
9
+ findUnique: vi.fn().mockResolvedValue(null),
10
+ },
11
+ emailPreference: { findUnique: vi.fn().mockResolvedValue(null) },
12
+ },
13
+ }))
14
+
15
+ vi.mock('../sender', () => ({
16
+ sendByDriver: vi.fn().mockResolvedValue({ ok: true, messageId: 'm1', bounced: false }),
17
+ resolveDriver: () => 'console',
18
+ }))
19
+
20
+ import { outboxSend } from '../outbox'
21
+ import { registerTemplate } from '../registry'
22
+ import { prisma } from '@allbluecn/database'
23
+
24
+ const GUEST_KEY = `test.guest-${Math.random().toString(36).slice(2, 8)}`
25
+ const TX_KEY = `test.tx-${Math.random().toString(36).slice(2, 8)}`
26
+ const NON_TX_KEY = `test.nontx-${Math.random().toString(36).slice(2, 8)}`
27
+
28
+ registerTemplate({
29
+ key: GUEST_KEY,
30
+ category: 'auth',
31
+ transactional: true,
32
+ subject: { zh: 'g', en: 'g' },
33
+ component: () => <div>g</div>,
34
+ })
35
+ registerTemplate({
36
+ key: TX_KEY,
37
+ category: 'auth',
38
+ transactional: true,
39
+ subject: { zh: 't', en: 't' },
40
+ component: () => <div>t</div>,
41
+ })
42
+ registerTemplate({
43
+ key: NON_TX_KEY,
44
+ category: 'notification',
45
+ transactional: false,
46
+ subject: { zh: 'n', en: 'n' },
47
+ component: () => <div>n</div>,
48
+ })
49
+
50
+ beforeEach(() => {
51
+ vi.clearAllMocks()
52
+ })
53
+
54
+ describe('outbox 管道', () => {
55
+ it('guest 身份邮件被短路跳过(不写日志不发送)', async () => {
56
+ const result = await outboxSend({
57
+ key: GUEST_KEY,
58
+ to: 'guest@localhost',
59
+ locale: 'zh',
60
+ userId: 'local-guest',
61
+ vars: {},
62
+ })
63
+ expect(result.skipped).toBe(true)
64
+ expect(result.skipReason).toBe('guest')
65
+ })
66
+
67
+ it('未注册模板抛错', async () => {
68
+ await expect(
69
+ outboxSend({ key: 'nope.missing', to: 'a@b.com', locale: 'zh', vars: {} }),
70
+ ).rejects.toThrow(/not registered/)
71
+ })
72
+
73
+ it('事务性模板跳过退订检查(无 userId 时直接发送)', async () => {
74
+ const result = await outboxSend({ key: TX_KEY, to: 'a@b.com', locale: 'zh', vars: {} })
75
+ expect(result.sent).toBe(true)
76
+ })
77
+
78
+ it('dedupeKey 已存在 → 跳过(不发送不写日志)', async () => {
79
+ vi.mocked(prisma.emailLog.findUnique).mockResolvedValueOnce({ id: 'old' } as never)
80
+ const result = await outboxSend({ key: TX_KEY, to: 'a@b.com', locale: 'zh', vars: {}, dedupeKey: 'dup-1' })
81
+ expect(result).toEqual({ sent: false, skipped: true, skipReason: 'dedupe' })
82
+ expect(prisma.emailLog.create).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('dedupeKey 并发撞唯一键(P2002)→ 回退跳过而非抛错', async () => {
86
+ const p2002 = Object.assign(new Error('Unique constraint failed'), { code: 'P2002' })
87
+ vi.mocked(prisma.emailLog.create).mockRejectedValueOnce(p2002)
88
+ const result = await outboxSend({ key: TX_KEY, to: 'a@b.com', locale: 'zh', vars: {}, dedupeKey: 'dup-2' })
89
+ expect(result).toEqual({ sent: false, skipped: true, skipReason: 'dedupe' })
90
+ expect(prisma.emailLog.update).not.toHaveBeenCalled()
91
+ })
92
+
93
+ it('dedupeKey 非唯一键错误 → 原样抛错', async () => {
94
+ vi.mocked(prisma.emailLog.create).mockRejectedValueOnce(new Error('db down'))
95
+ await expect(
96
+ outboxSend({ key: TX_KEY, to: 'a@b.com', locale: 'zh', vars: {}, dedupeKey: 'dup-3' }),
97
+ ).rejects.toThrow('db down')
98
+ })
99
+
100
+ it('非事务模板传 unsubscribeUrl → headers 使用覆盖 URL,不调 HMAC', async () => {
101
+ const customUrl = 'http://localhost:3000/email-unsubscribe?token=db-token'
102
+ await outboxSend({
103
+ key: NON_TX_KEY,
104
+ to: 'a@b.com',
105
+ locale: 'zh',
106
+ unsubscribeUrl: customUrl,
107
+ vars: {},
108
+ })
109
+ const { sendByDriver } = await import('../sender')
110
+ const msg = vi.mocked(sendByDriver).mock.calls.at(-1)?.[0]
111
+ expect(msg?.headers?.['List-Unsubscribe']).toContain(`<${customUrl}>`)
112
+ expect(msg?.headers?.['List-Unsubscribe']).toContain('<mailto:unsubscribe@allblue.local?subject=unsubscribe>')
113
+ expect(msg?.headers?.['List-Unsubscribe-Post']).toBe('One-Click=List-Unsubscribe')
114
+ })
115
+
116
+ it('非事务模板无 unsubscribeUrl 有 userId → 回落 HMAC 路径', async () => {
117
+ await outboxSend({
118
+ key: NON_TX_KEY,
119
+ to: 'a@b.com',
120
+ locale: 'zh',
121
+ userId: 'u1',
122
+ vars: {},
123
+ })
124
+ const { sendByDriver } = await import('../sender')
125
+ const msg = vi.mocked(sendByDriver).mock.calls.at(-1)?.[0]
126
+ expect(msg?.headers?.['List-Unsubscribe']).toContain('/email-unsubscribe?token=')
127
+ expect(msg?.headers?.['List-Unsubscribe-Post']).toBe('One-Click=List-Unsubscribe')
128
+ })
129
+ })
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ vi.mock('@allbluecn/database', () => ({
4
+ prisma: {
5
+ emailLog: {
6
+ create: vi.fn().mockResolvedValue({ id: 'log1' }),
7
+ update: vi.fn().mockResolvedValue({}),
8
+ count: vi.fn().mockResolvedValue(0),
9
+ findUnique: vi.fn().mockResolvedValue(null),
10
+ },
11
+ emailPreference: { findUnique: vi.fn().mockResolvedValue(null) },
12
+ },
13
+ }))
14
+
15
+ vi.mock('../sender', () => ({
16
+ sendByDriver: vi.fn().mockResolvedValue({ ok: true, messageId: 'm1', bounced: false }),
17
+ resolveDriver: () => 'console',
18
+ }))
19
+
20
+ vi.mock('../preferences', () => ({
21
+ checkEmailEligibility: vi.fn().mockResolvedValue({ sendable: true }),
22
+ }))
23
+
24
+ import { createElement } from 'react'
25
+ import { render } from '@react-email/render'
26
+ import { outboxSend } from '../outbox'
27
+ import { getTemplate } from '../registry'
28
+ import { registerChangelogConfirmTemplate } from '../templates/product/changelog-confirm'
29
+ import { registerChangelogReleaseTemplate } from '../templates/product/changelog-release'
30
+
31
+ registerChangelogConfirmTemplate()
32
+ registerChangelogReleaseTemplate()
33
+
34
+ describe('product 模板', () => {
35
+ it('confirm 模板渲染按钮与提示', async () => {
36
+ const meta = getTemplate('product.changelog-confirm')!
37
+ const html = await render(createElement(meta.component, {
38
+ locale: 'zh',
39
+ confirmUrl: 'http://x/changelog/confirm?token=abc',
40
+ } as never))
41
+ expect(html).toContain('确认订阅')
42
+ expect(html).toContain('24 小时')
43
+ expect(html).toContain('token=abc')
44
+ expect(meta.transactional).toBe(false)
45
+ expect(meta.subject.zh).toBe('确认订阅 AllBlue 开发更新')
46
+ })
47
+
48
+ it('release 模板渲染版本与 highlights', async () => {
49
+ const meta = getTemplate('product.changelog-release')!
50
+ const html = await render(createElement(meta.component, {
51
+ locale: 'zh',
52
+ version: '1.2.3',
53
+ highlights: '新编辑器\n更快的搜索',
54
+ detailUrl: 'http://x/changelog',
55
+ } as never))
56
+ expect(html).toContain('v1.2.3')
57
+ expect(html).toContain('新编辑器')
58
+ expect(html).toContain('更快的搜索')
59
+ expect(html).toContain('查看更新日志')
60
+ })
61
+
62
+ it('release 模板 highlights 为空时不渲染该段', async () => {
63
+ const meta = getTemplate('product.changelog-release')!
64
+ const html = await render(createElement(meta.component, {
65
+ locale: 'zh',
66
+ version: '1.2.3',
67
+ highlights: '',
68
+ detailUrl: 'http://x/changelog',
69
+ } as never))
70
+ expect(html).toContain('v1.2.3')
71
+ expect(html).not.toContain('新编辑器')
72
+ })
73
+
74
+ it('release subject 动态插值 version', () => {
75
+ const meta = getTemplate('product.changelog-release')!
76
+ expect(typeof meta.subject.zh).toBe('function')
77
+ expect((meta.subject.zh as (v: Record<string, unknown>) => string)({ version: '9.9.9' })).toBe('AllBlue v9.9.9 发布')
78
+ expect((meta.subject.en as (v: Record<string, unknown>) => string)({ version: '9.9.9' })).toBe('AllBlue v9.9.9 released')
79
+ })
80
+
81
+ it('outbox 解析函数式 subject 并发送', async () => {
82
+ const r = await outboxSend({
83
+ key: 'product.changelog-release',
84
+ to: 'a@b.com',
85
+ locale: 'zh',
86
+ vars: { version: '1.0.0', highlights: '', detailUrl: 'http://x/changelog' },
87
+ })
88
+ expect(r).toEqual({ sent: true })
89
+ const { sendByDriver } = await import('../sender')
90
+ const msg = vi.mocked(sendByDriver).mock.calls.at(-1)?.[0]
91
+ expect(msg?.subject).toBe('AllBlue v1.0.0 发布')
92
+ })
93
+
94
+ it('confirm 模板游客路径(无 userId)跳过退订头', async () => {
95
+ await outboxSend({
96
+ key: 'product.changelog-confirm',
97
+ to: 'a@b.com',
98
+ locale: 'zh',
99
+ vars: { confirmUrl: 'http://x/c?token=t' },
100
+ })
101
+ const { sendByDriver } = await import('../sender')
102
+ const msg = vi.mocked(sendByDriver).mock.calls.at(-1)?.[0]
103
+ expect(msg?.headers).toEqual({})
104
+ expect(msg?.subject).toBe('确认订阅 AllBlue 开发更新')
105
+ })
106
+ })
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { registerTemplate, getTemplate, listTemplates } from '../registry'
3
+
4
+ const UNIQUE = `test.hello-${Math.random().toString(36).slice(2, 8)}`
5
+
6
+ describe('email template registry', () => {
7
+ it('注册后可按 key 取回元数据', () => {
8
+ const Comp = () => <div>hi</div>
9
+ registerTemplate({
10
+ key: UNIQUE,
11
+ category: 'auth',
12
+ transactional: true,
13
+ subject: { zh: '你好', en: 'Hello' },
14
+ component: Comp,
15
+ })
16
+ const meta = getTemplate(UNIQUE)!
17
+ expect(meta).toBeDefined()
18
+ expect(meta.subject.zh).toBe('你好')
19
+ expect(meta.transactional).toBe(true)
20
+ expect(meta.component).toBe(Comp)
21
+ })
22
+
23
+ it('未注册 key 返回 undefined', () => {
24
+ expect(getTemplate('test.not-exist')).toBeUndefined()
25
+ })
26
+
27
+ it('重复注册同 key 抛错(防拼写冲突)', () => {
28
+ const dupKey = `test.dup-${Math.random().toString(36).slice(2, 8)}`
29
+ registerTemplate({
30
+ key: dupKey,
31
+ category: 'auth',
32
+ transactional: true,
33
+ subject: { zh: 'a', en: 'a' },
34
+ component: () => <div />,
35
+ })
36
+ expect(() =>
37
+ registerTemplate({
38
+ key: dupKey,
39
+ category: 'auth',
40
+ transactional: true,
41
+ subject: { zh: 'a', en: 'a' },
42
+ component: () => <div />,
43
+ }),
44
+ ).toThrow(/already registered/)
45
+ })
46
+
47
+ it('listTemplates 返回全部 key', () => {
48
+ expect(listTemplates().map((t) => t.key)).toContain(UNIQUE)
49
+ })
50
+ })
@@ -0,0 +1,57 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto'
2
+
3
+ const UNSUB_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000 // 10 年(退订链接长期有效)
4
+
5
+ function secret(): string {
6
+ return process.env.AUTH_SECRET ?? 'dev-only-secret'
7
+ }
8
+
9
+ export interface UnsubscribePayload {
10
+ userId: string
11
+ email: string
12
+ category: string
13
+ exp: number
14
+ }
15
+
16
+ function base64url(input: string | Buffer): string {
17
+ return Buffer.from(input).toString('base64url')
18
+ }
19
+
20
+ export function signUnsubscribeToken(
21
+ data: { userId: string; email: string; category: string },
22
+ ttlMs: number = UNSUB_TTL_MS,
23
+ ): string {
24
+ const payload: UnsubscribePayload = { ...data, exp: Date.now() + ttlMs }
25
+ const body = base64url(JSON.stringify(payload))
26
+ const sig = createHmac('sha256', secret()).update(body).digest('base64url')
27
+ return `${body}.${sig}`
28
+ }
29
+
30
+ export function verifyUnsubscribeToken(token: string): UnsubscribePayload | null {
31
+ const [body, sig] = token.split('.')
32
+ if (!body || !sig) return null
33
+ const expected = createHmac('sha256', secret()).update(body).digest()
34
+ const got = Buffer.from(sig, 'base64url')
35
+ if (expected.length !== got.length || !timingSafeEqual(expected, got)) return null
36
+ try {
37
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as UnsubscribePayload
38
+ if (Date.now() > payload.exp) return null
39
+ return payload
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ export function buildUnsubscribeHeaders(params: {
46
+ appUrl: string
47
+ userId: string
48
+ email: string
49
+ category: string
50
+ }): Record<string, string> {
51
+ const token = signUnsubscribeToken({ userId: params.userId, email: params.email, category: params.category })
52
+ const url = `${params.appUrl}/email-unsubscribe?token=${encodeURIComponent(token)}`
53
+ return {
54
+ 'List-Unsubscribe': `<${url}>, <mailto:unsubscribe@allblue.local?subject=unsubscribe>`,
55
+ 'List-Unsubscribe-Post': 'One-Click=List-Unsubscribe',
56
+ }
57
+ }
@@ -0,0 +1,11 @@
1
+ import type { EmailMessage } from '../sender'
2
+
3
+ function maskEmail(address: string): string {
4
+ const [local, domain] = address.split('@')
5
+ return domain && local ? `${local.slice(0, 1)}***@${domain}` : '***'
6
+ }
7
+
8
+ export async function sendByConsole(msg: EmailMessage): Promise<{ messageId: string | null; bounced: boolean }> {
9
+ console.log(`[email:console] To: ${maskEmail(msg.to)}, Subject: ${msg.subject}, ${msg.text?.length ?? 0} chars text`)
10
+ return { messageId: null, bounced: false }
11
+ }
@@ -0,0 +1,31 @@
1
+ import type { EmailMessage } from '../sender'
2
+
3
+ interface ResendSendResponse {
4
+ id?: string
5
+ message?: string
6
+ }
7
+
8
+ export async function sendByResend(msg: EmailMessage): Promise<{ messageId: string | null; bounced: boolean }> {
9
+ const res = await fetch('https://api.resend.com/emails', {
10
+ method: 'POST',
11
+ headers: {
12
+ Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
13
+ 'Content-Type': 'application/json',
14
+ },
15
+ body: JSON.stringify({
16
+ from: process.env.EMAIL_FROM,
17
+ to: [msg.to],
18
+ reply_to: msg.replyTo,
19
+ subject: msg.subject,
20
+ html: msg.html,
21
+ text: msg.text,
22
+ headers: msg.headers,
23
+ }),
24
+ })
25
+ if (!res.ok) {
26
+ const body = (await res.json().catch(() => ({}))) as ResendSendResponse
27
+ throw new Error(`Resend ${res.status}: ${body.message ?? 'send failed'}`)
28
+ }
29
+ const data = (await res.json()) as ResendSendResponse
30
+ return { messageId: data.id ?? null, bounced: false }
31
+ }
@@ -0,0 +1,27 @@
1
+ import nodemailer from 'nodemailer'
2
+ import type { EmailMessage } from '../sender'
3
+
4
+ export async function sendBySmtp(msg: EmailMessage): Promise<{ messageId: string | null; bounced: boolean }> {
5
+ const transport = nodemailer.createTransport({
6
+ host: process.env.EMAIL_HOST,
7
+ port: Number(process.env.EMAIL_PORT ?? 587),
8
+ secure: Number(process.env.EMAIL_PORT) === 465,
9
+ auth: process.env.EMAIL_USER
10
+ ? { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
11
+ : undefined,
12
+ })
13
+ try {
14
+ const info = await transport.sendMail({
15
+ from: process.env.EMAIL_FROM ?? 'AllBlue <no-reply@localhost>',
16
+ to: msg.to,
17
+ replyTo: msg.replyTo,
18
+ subject: msg.subject,
19
+ html: msg.html,
20
+ text: msg.text,
21
+ headers: msg.headers,
22
+ })
23
+ return { messageId: info.messageId ?? null, bounced: info.rejected.length > 0 }
24
+ } finally {
25
+ transport.close()
26
+ }
27
+ }
@@ -0,0 +1,56 @@
1
+ import { outboxSend, generateEmailToken } from './outbox'
2
+ import type { EmailLocale } from './registry'
3
+
4
+ import { registerVerifyEmailTemplate } from './templates/auth/verify-email'
5
+ import { registerResetPasswordTemplate } from './templates/auth/reset-password'
6
+ import { registerWelcomeTemplate } from './templates/auth/welcome'
7
+ import { registerNotificationMirrorTemplate } from './templates/notification/mirror'
8
+ import { registerPaymentReceiptTemplate } from './templates/billing/payment-receipt'
9
+ import { registerSubscriptionEndingTemplate } from './templates/billing/subscription-ending'
10
+ import { registerSubscriptionCanceledTemplate } from './templates/billing/subscription-canceled'
11
+ import { registerChangelogConfirmTemplate } from './templates/product/changelog-confirm'
12
+ import { registerChangelogReleaseTemplate } from './templates/product/changelog-release'
13
+ import { registerTeamInviteTemplate } from './templates/team/invite'
14
+ import { registerTeamApplyNotifyTemplate } from './templates/team/apply-notify'
15
+ import { registerTeamApprovedTemplate } from './templates/team/approved'
16
+
17
+ let registered = false
18
+ function ensureRegistered() {
19
+ if (registered) return
20
+ registerVerifyEmailTemplate()
21
+ registerResetPasswordTemplate()
22
+ registerWelcomeTemplate()
23
+ registerNotificationMirrorTemplate()
24
+ registerPaymentReceiptTemplate()
25
+ registerSubscriptionEndingTemplate()
26
+ registerSubscriptionCanceledTemplate()
27
+ registerChangelogConfirmTemplate()
28
+ registerChangelogReleaseTemplate()
29
+ registerTeamInviteTemplate()
30
+ registerTeamApplyNotifyTemplate()
31
+ registerTeamApprovedTemplate()
32
+ registered = true
33
+ }
34
+
35
+ export interface SendTemplateEmailParams {
36
+ key: string
37
+ to: string
38
+ locale?: string
39
+ userId?: string
40
+ dedupeKey?: string
41
+ vars: Record<string, unknown>
42
+ /** 覆盖默认 HMAC 退订 URL(DB-token 场景,如 changelog 订阅者退订)。 */
43
+ unsubscribeUrl?: string
44
+ /** 回复收件人(如团队邀请邮件的邀请人邮箱),透传给底层 driver。 */
45
+ replyTo?: string
46
+ }
47
+
48
+ export async function sendTemplateEmail(
49
+ params: SendTemplateEmailParams,
50
+ ): Promise<{ sent: boolean; skipped?: boolean; skipReason?: string }> {
51
+ ensureRegistered()
52
+ const locale: EmailLocale = params.locale === 'en' ? 'en' : 'zh'
53
+ return outboxSend({ ...params, locale })
54
+ }
55
+
56
+ export { generateEmailToken }