@allbluecn/web-app 0.11.0 → 0.11.2

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
@@ -0,0 +1,110 @@
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 { createFileRoute } from '@tanstack/react-router'
19
+ import { prisma } from '@allbluecn/database'
20
+ import { sendTemplateEmail } from '@/server/email'
21
+
22
+ const DAY_MS = 24 * 60 * 60 * 1000
23
+
24
+ const WINDOWS = [
25
+ { days: 7, offsetMs: 7 * DAY_MS },
26
+ { days: 3, offsetMs: 3 * DAY_MS },
27
+ { days: 1, offsetMs: 1 * DAY_MS },
28
+ ] as const
29
+
30
+ function startOfDay(d: Date): Date {
31
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
32
+ }
33
+
34
+ function dateOnly(d: Date): string {
35
+ return d.toISOString().slice(0, 10)
36
+ }
37
+
38
+ async function isAuthorized(request: Request): Promise<boolean> {
39
+ const secret = process.env.CRON_SECRET
40
+ if (!secret) {
41
+ return process.env.NODE_ENV !== 'production'
42
+ }
43
+ return (request.headers.get('authorization') ?? '') === `Bearer ${secret}`
44
+ }
45
+
46
+ export const Route = createFileRoute('/api/cron/subscription-reminders')({
47
+ server: {
48
+ handlers: {
49
+ GET: async ({ request }) => {
50
+ if (!(await isAuthorized(request))) {
51
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
52
+ }
53
+
54
+ const todayStart = startOfDay(new Date())
55
+ const wideUntil = new Date(todayStart.getTime() + WINDOWS[0].offsetMs)
56
+
57
+ const subs = await prisma.subscription.findMany({
58
+ where: {
59
+ OR: [
60
+ {
61
+ status: 'ACTIVE',
62
+ cancelAtPeriodEnd: true,
63
+ currentPeriodEnd: { gte: todayStart, lte: wideUntil },
64
+ },
65
+ {
66
+ status: 'TRIALING',
67
+ trialEndsAt: { gte: todayStart, lte: wideUntil },
68
+ },
69
+ ],
70
+ },
71
+ include: {
72
+ user: { select: { email: true, locale: true } },
73
+ plan: { select: { displayName: true } },
74
+ },
75
+ })
76
+
77
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
78
+ let processed = 0
79
+
80
+ for (const sub of subs) {
81
+ if (!sub.user?.email || !sub.plan) continue
82
+ const endDate = sub.status === 'TRIALING' ? sub.trialEndsAt : sub.currentPeriodEnd
83
+ if (!endDate) continue
84
+ const endDateStr = dateOnly(endDate)
85
+
86
+ for (const w of WINDOWS) {
87
+ const until = new Date(todayStart.getTime() + w.offsetMs)
88
+ if (endDate < todayStart || endDate > until) continue
89
+ const dedupeKey = `sub-reminder:${sub.id}:${endDateStr}:${w.days}`
90
+ const res = await sendTemplateEmail({
91
+ key: 'billing.subscription-ending',
92
+ to: sub.user.email,
93
+ locale: sub.user.locale ?? 'zh',
94
+ userId: sub.userId,
95
+ dedupeKey,
96
+ vars: {
97
+ planName: sub.plan.displayName,
98
+ endDate: endDateStr,
99
+ dashboardUrl: `${appUrl}/settings/billing`,
100
+ },
101
+ })
102
+ if (res.sent) processed++
103
+ }
104
+ }
105
+
106
+ return Response.json({ processed })
107
+ },
108
+ },
109
+ },
110
+ })
@@ -16,16 +16,14 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
- import { authConfig } from '@/lib/auth/auth.config'
19
+ import { getAuthConfig } from '@/lib/auth/auth.config'
20
20
  import { StartAuthJS } from 'start-authjs'
21
21
 
22
- const authHandlers = StartAuthJS(authConfig)
23
-
24
22
  export const Route = createFileRoute('/api/auth/$')({
25
23
  server: {
26
24
  handlers: {
27
- GET: ({ request }) => authHandlers.GET({ request }),
28
- POST: ({ request }) => authHandlers.POST({ request }),
25
+ GET: async ({ request }) => (await StartAuthJS(await getAuthConfig())).GET({ request }),
26
+ POST: async ({ request }) => (await StartAuthJS(await getAuthConfig())).POST({ request }),
29
27
  },
30
28
  },
31
29
  })
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { consumeInvitationCode, type ConsumeResult } from '../auth-registration'
3
+ import type { Prisma } from '@allbluecn/database'
4
+
5
+ type TxMock = {
6
+ invitationCode: {
7
+ findUnique: ReturnType<typeof vi.fn>
8
+ updateMany: ReturnType<typeof vi.fn>
9
+ }
10
+ }
11
+
12
+ function makeTx(overrides: Record<string, unknown> = {}): { tx: Prisma.TransactionClient; mock: TxMock } {
13
+ const mock: TxMock = {
14
+ invitationCode: {
15
+ findUnique: vi.fn().mockResolvedValue({ id: 'code_1', maxUses: 2 }),
16
+ updateMany: vi.fn().mockResolvedValue({ count: 1 }),
17
+ ...overrides,
18
+ },
19
+ }
20
+ return { tx: mock as unknown as Prisma.TransactionClient, mock }
21
+ }
22
+
23
+ describe('consumeInvitationCode', () => {
24
+ it('有效邀请码:占一位并返回 codeId', async () => {
25
+ const { tx, mock } = makeTx()
26
+ const r: ConsumeResult = await consumeInvitationCode(tx, 'ABC')
27
+ expect(r).toEqual({ ok: true, codeId: 'code_1' })
28
+ expect(mock.invitationCode.updateMany).toHaveBeenCalledWith({
29
+ where: expect.objectContaining({
30
+ id: 'code_1',
31
+ usedCount: { lt: 2 },
32
+ disabledAt: null,
33
+ expiresAt: { gt: expect.any(Date) },
34
+ }),
35
+ data: { usedCount: { increment: 1 } },
36
+ })
37
+ })
38
+
39
+ it('邀请码不存在:返回「邀请码无效」', async () => {
40
+ const { tx, mock } = makeTx({ findUnique: vi.fn().mockResolvedValue(null) })
41
+ const r = await consumeInvitationCode(tx, 'NOPE')
42
+ expect(r).toEqual({ ok: false, error: '邀请码无效' })
43
+ expect(mock.invitationCode.updateMany).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it('配额耗尽/过期/禁用:updateMany count=0 → 返回用尽错误', async () => {
47
+ const { tx } = makeTx({ updateMany: vi.fn().mockResolvedValue({ count: 0 }) })
48
+ const r = await consumeInvitationCode(tx, 'FULL')
49
+ expect(r).toEqual({ ok: false, error: '邀请码已用尽、过期或被禁用' })
50
+ })
51
+
52
+ it('邀请码大小写与首尾空格归一化后匹配', async () => {
53
+ const { tx, mock } = makeTx()
54
+ await consumeInvitationCode(tx, ' abc ')
55
+ expect(mock.invitationCode.findUnique).toHaveBeenCalledWith({
56
+ where: { code: 'ABC' },
57
+ select: { id: true, maxUses: true },
58
+ })
59
+ })
60
+ })
@@ -0,0 +1,40 @@
1
+ import { prisma } from '@allbluecn/database'
2
+ import type { Prisma } from '@allbluecn/database'
3
+
4
+ export type RegistrationMode = 'invite' | 'open'
5
+
6
+ export async function getRegistrationMode(): Promise<RegistrationMode> {
7
+ const setting = await prisma.systemSetting.findUnique({ where: { key: 'auth.registrationMode' } })
8
+ return setting?.value === 'open' ? 'open' : 'invite'
9
+ }
10
+
11
+ export type ConsumeResult = { ok: true; codeId: string } | { ok: false; error: string }
12
+
13
+ /**
14
+ * 事务内占用邀请码配额(不写 InvitationUse——调用时用户尚未创建,拿不到 userId)。
15
+ * 成功即配额已扣;调用方事务回滚则配额占用一并回滚,无泄露。
16
+ */
17
+ export async function consumeInvitationCode(
18
+ tx: Prisma.TransactionClient,
19
+ code: string,
20
+ ): Promise<ConsumeResult> {
21
+ const normalized = code.trim().toUpperCase()
22
+ const row = await tx.invitationCode.findUnique({
23
+ where: { code: normalized },
24
+ select: { id: true, maxUses: true },
25
+ })
26
+ if (!row) return { ok: false, error: '邀请码无效' }
27
+ const affected = await tx.invitationCode.updateMany({
28
+ where: {
29
+ id: row.id,
30
+ usedCount: { lt: row.maxUses },
31
+ disabledAt: null,
32
+ expiresAt: { gt: new Date() },
33
+ },
34
+ data: { usedCount: { increment: 1 } },
35
+ })
36
+ if (affected.count === 0) {
37
+ return { ok: false, error: '邀请码已用尽、过期或被禁用' }
38
+ }
39
+ return { ok: true, codeId: row.id }
40
+ }
@@ -0,0 +1,39 @@
1
+ import { prisma } from '@allbluecn/database'
2
+ import { sendTemplateEmail } from '@/server/email'
3
+
4
+ interface CreemEvent {
5
+ eventType?: string
6
+ /** Creem webhook payload:subscription 对象挂在 `object` 上,订阅 id 用 `object.id`。 */
7
+ object?: { id?: string; subscriptionId?: string; metadata?: Record<string, string> }
8
+ }
9
+
10
+ export async function sendBillingEmailForEvent(rawBody: string): Promise<void> {
11
+ try {
12
+ const evt = JSON.parse(rawBody) as CreemEvent
13
+ // 官方路径 `object.id`;保留 `object.subscriptionId` fallback 兜底旧版/非标准 payload。
14
+ const subId = evt.object?.id ?? evt.object?.subscriptionId
15
+ const type = evt.eventType
16
+ if (!subId || (type !== 'subscription.paid' && type !== 'subscription.canceled')) return
17
+ const sub = await prisma.subscription.findUnique({
18
+ where: { providerSubscriptionId: subId },
19
+ select: { userId: true, currentPeriodEnd: true, plan: { select: { displayName: true } } },
20
+ })
21
+ if (!sub) return
22
+ const user = await prisma.user.findUnique({
23
+ where: { id: sub.userId },
24
+ select: { email: true, locale: true },
25
+ })
26
+ if (!user?.email) return
27
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
28
+ const periodEnd = (sub.currentPeriodEnd ?? new Date()).toISOString().slice(0, 10)
29
+ await sendTemplateEmail({
30
+ key: type === 'subscription.paid' ? 'billing.payment-receipt' : 'billing.subscription-canceled',
31
+ to: user.email,
32
+ locale: user.locale ?? 'zh',
33
+ userId: sub.userId,
34
+ vars: type === 'subscription.paid'
35
+ ? { planName: sub.plan.displayName, periodEnd, dashboardUrl: `${appUrl}/settings/billing` }
36
+ : { planName: sub.plan.displayName, endDate: periodEnd, dashboardUrl: `${appUrl}/settings/billing` },
37
+ })
38
+ } catch { /* 计费邮件失败不影响 webhook 200 */ }
39
+ }
@@ -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
+ })