@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.
- package/package.json +24 -11
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.png +0 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
- package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/auth/icon-input.tsx +9 -1
- package/src/components/auth/password-input.tsx +9 -1
- package/src/components/changelog/changelog-page.tsx +44 -1
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/forms/app-field.tsx +3 -1
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/fetch-json.ts +23 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +10 -2
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routeTree.gen.ts +114 -0
- package/src/routes/__root.tsx +20 -3
- package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
- package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
- package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
- package/src/routes/_login/changelog/confirm/route.tsx +28 -0
- package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
- package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
- package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
- package/src/routes/_login/login/route.lazy.tsx +4 -2
- package/src/routes/_login/register/route.lazy.tsx +28 -17
- package/src/routes/_login/register/route.tsx +8 -1
- package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
- package/src/routes/_login/schemas/-auth.ts +30 -7
- package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
- package/src/routes/_login/verify-email/route.tsx +28 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +26 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/_public/index/route.tsx +2 -1
- package/src/routes/api/auth/desktop-callback.ts +2 -2
- package/src/routes/api/billing-webhook.ts +4 -0
- package/src/routes/api/cron/changelog-release.ts +100 -0
- package/src/routes/api/cron/subscription-reminders.ts +110 -0
- package/src/routes/api.auth.$.ts +3 -5
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/auth-registration.test.ts +60 -0
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -0
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/__tests__/deliverability.test.ts +68 -0
- package/src/server/email/__tests__/index.test.ts +32 -0
- package/src/server/email/__tests__/outbox.test.tsx +129 -0
- package/src/server/email/__tests__/product-templates.test.tsx +106 -0
- package/src/server/email/__tests__/registry.test.tsx +50 -0
- package/src/server/email/deliverability.ts +57 -0
- package/src/server/email/drivers/console.ts +11 -0
- package/src/server/email/drivers/resend.ts +31 -0
- package/src/server/email/drivers/smtp.ts +27 -0
- package/src/server/email/index.ts +56 -0
- package/src/server/email/outbox.ts +141 -0
- package/src/server/email/preferences.ts +20 -0
- package/src/server/email/registry.ts +38 -0
- package/src/server/email/sender.ts +36 -13
- package/src/server/email/templates/_base/button.tsx +12 -0
- package/src/server/email/templates/_base/email-layout.tsx +117 -0
- package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
- package/src/server/email/templates/_base/theme.ts +22 -0
- package/src/server/email/templates/auth/reset-password.tsx +58 -0
- package/src/server/email/templates/auth/verify-email.tsx +59 -0
- package/src/server/email/templates/auth/welcome.tsx +49 -0
- package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
- package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
- package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
- package/src/server/email/templates/notification/mirror.tsx +50 -0
- package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
- package/src/server/email/templates/product/changelog-release.tsx +65 -0
- package/src/server/email/templates/team/apply-notify.tsx +53 -0
- package/src/server/email/templates/team/approved.tsx +53 -0
- package/src/server/email/templates/team/invite.tsx +57 -0
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +122 -0
- package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
- package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
- package/src/server/notifications/notification-service.ts +71 -1
- package/src/server/notifications/verify-reminder.ts +55 -0
- package/src/server/oauth-config.ts +59 -0
- package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/auth/email-verification.ts +132 -0
- package/src/server/serverFns/auth/forgot-password.ts +22 -2
- package/src/server/serverFns/auth/providers.ts +4 -3
- package/src/server/serverFns/auth/register.ts +63 -19
- package/src/server/serverFns/auth/registration-mode.ts +23 -0
- package/src/server/serverFns/auth/reset-password.ts +1 -1
- package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
- package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
- package/src/server/serverFns/changelog/subscribe.ts +89 -0
- package/src/server/serverFns/email-unsubscribe.ts +66 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/notifications.ts +10 -0
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +50 -12
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +114 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +105 -4
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /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 }
|