@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,141 @@
|
|
|
1
|
+
import { render } from '@react-email/render'
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import { randomBytes } from 'crypto'
|
|
4
|
+
import { prisma } from '@allbluecn/database'
|
|
5
|
+
import { GUEST_USER_ID } from '@/server/desktop-guest'
|
|
6
|
+
import { getTemplate, type EmailCategory, type EmailLocale } from './registry'
|
|
7
|
+
import { sendByDriver, resolveDriver } from './sender'
|
|
8
|
+
import { checkEmailEligibility } from './preferences'
|
|
9
|
+
import { buildUnsubscribeHeaders } from './deliverability'
|
|
10
|
+
|
|
11
|
+
const DAILY_LIMIT_BY_CATEGORY: Partial<Record<EmailCategory, number>> = {
|
|
12
|
+
notification: 10,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isUniqueViolation(err: unknown): boolean {
|
|
16
|
+
return (err as { code?: string })?.code === 'P2002'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OutboxSendParams {
|
|
20
|
+
key: string
|
|
21
|
+
to: string
|
|
22
|
+
locale: EmailLocale
|
|
23
|
+
userId?: string
|
|
24
|
+
dedupeKey?: string
|
|
25
|
+
vars: Record<string, unknown>
|
|
26
|
+
/** 覆盖默认 HMAC 退订 URL(DB-token 场景,如 changelog 订阅者)。
|
|
27
|
+
* 传入则走覆盖 URL 构造 List-Unsubscribe 头,不调 HMAC;未传则回落到 userId HMAC 路径。 */
|
|
28
|
+
unsubscribeUrl?: string
|
|
29
|
+
/** 回复收件人(如团队邀请邮件的邀请人邮箱),透传给底层 driver。 */
|
|
30
|
+
replyTo?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OutboxSendResult {
|
|
34
|
+
sent: boolean
|
|
35
|
+
skipped?: boolean
|
|
36
|
+
skipReason?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function outboxSend(params: OutboxSendParams): Promise<OutboxSendResult> {
|
|
40
|
+
const meta = getTemplate(params.key)
|
|
41
|
+
if (!meta) throw new Error(`email template "${params.key}" not registered`)
|
|
42
|
+
|
|
43
|
+
// 邮箱大小写不敏感:入口归一化,下游速率计数 / HMAC 退订 / EmailLog / driver 全一致。
|
|
44
|
+
const to = params.to.toLowerCase()
|
|
45
|
+
|
|
46
|
+
if (params.userId === GUEST_USER_ID || to === 'guest@localhost') {
|
|
47
|
+
return { sent: false, skipped: true, skipReason: 'guest' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (params.dedupeKey) {
|
|
51
|
+
const existing = await prisma.emailLog.findUnique({ where: { dedupeKey: params.dedupeKey } })
|
|
52
|
+
if (existing) return { sent: false, skipped: true, skipReason: 'dedupe' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dailyLimit = DAILY_LIMIT_BY_CATEGORY[meta.category] ?? 50
|
|
56
|
+
const dayStart = new Date()
|
|
57
|
+
dayStart.setHours(0, 0, 0, 0)
|
|
58
|
+
const sentToday = await prisma.emailLog.count({
|
|
59
|
+
where: { to, category: meta.category, sentAt: { gte: dayStart } },
|
|
60
|
+
})
|
|
61
|
+
if (sentToday >= dailyLimit) {
|
|
62
|
+
return { sent: false, skipped: true, skipReason: 'rate-limit' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!meta.transactional && params.userId) {
|
|
66
|
+
const eligibility = await checkEmailEligibility(params.userId, meta.category)
|
|
67
|
+
if (!eligibility.sendable) {
|
|
68
|
+
return { sent: false, skipped: true, skipReason: eligibility.reason }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const element = createElement(
|
|
73
|
+
meta.component,
|
|
74
|
+
{ ...params.vars, locale: params.locale, unsubscribeUrl: params.unsubscribeUrl } as never,
|
|
75
|
+
)
|
|
76
|
+
const html = await render(element)
|
|
77
|
+
const text = await render(element, { plainText: true })
|
|
78
|
+
const rawSubject = meta.subject[params.locale]
|
|
79
|
+
const subject = typeof rawSubject === 'function' ? rawSubject(params.vars) : rawSubject
|
|
80
|
+
const headers: Record<string, string> = {}
|
|
81
|
+
if (!meta.transactional) {
|
|
82
|
+
if (params.unsubscribeUrl) {
|
|
83
|
+
headers['List-Unsubscribe'] = `<${params.unsubscribeUrl}>, <mailto:unsubscribe@allblue.local?subject=unsubscribe>`
|
|
84
|
+
headers['List-Unsubscribe-Post'] = 'One-Click=List-Unsubscribe'
|
|
85
|
+
} else if (params.userId) {
|
|
86
|
+
Object.assign(
|
|
87
|
+
headers,
|
|
88
|
+
buildUnsubscribeHeaders({
|
|
89
|
+
appUrl: process.env.VITE_APP_URL ?? 'http://localhost:3000',
|
|
90
|
+
userId: params.userId,
|
|
91
|
+
email: to,
|
|
92
|
+
category: meta.category,
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
let log: { id: string }
|
|
98
|
+
try {
|
|
99
|
+
log = await prisma.emailLog.create({
|
|
100
|
+
data: {
|
|
101
|
+
to,
|
|
102
|
+
templateKey: params.key,
|
|
103
|
+
category: meta.category,
|
|
104
|
+
status: 'PENDING',
|
|
105
|
+
provider: resolveDriver(),
|
|
106
|
+
dedupeKey: params.dedupeKey,
|
|
107
|
+
userId: params.userId,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// 并发同 dedupeKey:前置 findUnique 未命中但 create 撞唯一键,
|
|
112
|
+
// 说明另一条同键记录刚写入——视为已发送过,直接跳过而非抛错。
|
|
113
|
+
if (params.dedupeKey && isUniqueViolation(err)) {
|
|
114
|
+
return { sent: false, skipped: true, skipReason: 'dedupe' }
|
|
115
|
+
}
|
|
116
|
+
throw err
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = await sendByDriver({
|
|
120
|
+
to,
|
|
121
|
+
subject,
|
|
122
|
+
html,
|
|
123
|
+
text,
|
|
124
|
+
headers,
|
|
125
|
+
replyTo: params.replyTo,
|
|
126
|
+
})
|
|
127
|
+
await prisma.emailLog.update({
|
|
128
|
+
where: { id: log.id },
|
|
129
|
+
data: {
|
|
130
|
+
status: result.ok ? 'SENT' : 'FAILED',
|
|
131
|
+
messageId: result.messageId,
|
|
132
|
+
error: result.error,
|
|
133
|
+
sentAt: result.ok ? new Date() : undefined,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
return { sent: result.ok }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function generateEmailToken(): string {
|
|
140
|
+
return randomBytes(32).toString('hex')
|
|
141
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { prisma } from '@allbluecn/database'
|
|
2
|
+
import type { EmailCategory } from './registry'
|
|
3
|
+
|
|
4
|
+
export interface EmailSendEligibility {
|
|
5
|
+
sendable: boolean
|
|
6
|
+
reason?: 'unsubscribed-all' | 'unsubscribed-category' | 'suppressed'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function checkEmailEligibility(userId: string, category: EmailCategory): Promise<EmailSendEligibility> {
|
|
10
|
+
const pref = await prisma.emailPreference.findUnique({
|
|
11
|
+
where: { userId },
|
|
12
|
+
select: { suppressed: true, unsubscribedAll: true, unsubscribedCategories: true },
|
|
13
|
+
})
|
|
14
|
+
if (!pref) return { sendable: true }
|
|
15
|
+
if (pref.suppressed) return { sendable: false, reason: 'suppressed' }
|
|
16
|
+
if (pref.unsubscribedAll) return { sendable: false, reason: 'unsubscribed-all' }
|
|
17
|
+
const cats = Array.isArray(pref.unsubscribedCategories) ? (pref.unsubscribedCategories as string[]) : []
|
|
18
|
+
if (cats.includes(category)) return { sendable: false, reason: 'unsubscribed-category' }
|
|
19
|
+
return { sendable: true }
|
|
20
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactElement } from 'react'
|
|
2
|
+
|
|
3
|
+
export type EmailLocale = 'zh' | 'en'
|
|
4
|
+
|
|
5
|
+
export type EmailCategory = 'auth' | 'team' | 'notification' | 'billing' | 'product'
|
|
6
|
+
|
|
7
|
+
export interface EmailTemplateProps {
|
|
8
|
+
locale: EmailLocale
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type EmailSubject = string | ((vars: Record<string, unknown>) => string)
|
|
12
|
+
|
|
13
|
+
export interface TemplateMeta<TProps = EmailTemplateProps> {
|
|
14
|
+
key: string
|
|
15
|
+
category: EmailCategory
|
|
16
|
+
transactional: boolean
|
|
17
|
+
subject: { zh: EmailSubject; en: EmailSubject }
|
|
18
|
+
component: (props: TProps) => ReactElement
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// any 为刻意的类型逃逸:模板 props 由各模板组件自带签名,
|
|
22
|
+
// Map 需接受任意 props 形状;消费端(outbox)用运行时插值传 vars。
|
|
23
|
+
const REGISTRY = new Map<string, TemplateMeta<any>>()
|
|
24
|
+
|
|
25
|
+
export function registerTemplate<TProps>(meta: TemplateMeta<TProps>): void {
|
|
26
|
+
if (REGISTRY.has(meta.key)) {
|
|
27
|
+
throw new Error(`email template "${meta.key}" already registered`)
|
|
28
|
+
}
|
|
29
|
+
REGISTRY.set(meta.key, meta)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getTemplate(key: string): TemplateMeta | undefined {
|
|
33
|
+
return REGISTRY.get(key)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listTemplates(): TemplateMeta[] {
|
|
37
|
+
return [...REGISTRY.values()]
|
|
38
|
+
}
|
|
@@ -1,21 +1,44 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface EmailMessage {
|
|
2
2
|
to: string
|
|
3
3
|
subject: string
|
|
4
|
-
html
|
|
4
|
+
html: string
|
|
5
5
|
text?: string
|
|
6
|
+
headers?: Record<string, string>
|
|
7
|
+
replyTo?: string
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
export interface
|
|
9
|
-
|
|
10
|
+
export interface SendResult {
|
|
11
|
+
ok: boolean
|
|
12
|
+
messageId: string | null
|
|
13
|
+
bounced: boolean
|
|
14
|
+
error?: string
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
export type EmailDriver = 'smtp' | 'resend' | 'console'
|
|
18
|
+
|
|
19
|
+
export function resolveDriver(): EmailDriver {
|
|
20
|
+
const v = process.env.EMAIL_DRIVER
|
|
21
|
+
if (v === 'smtp' || v === 'resend' || v === 'console') return v
|
|
22
|
+
return 'console'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function sendByDriver(msg: EmailMessage): Promise<SendResult> {
|
|
26
|
+
const driver = resolveDriver()
|
|
27
|
+
try {
|
|
28
|
+
if (driver === 'smtp') {
|
|
29
|
+
const { sendBySmtp } = await import('./drivers/smtp')
|
|
30
|
+
const r = await sendBySmtp(msg)
|
|
31
|
+
return { ok: true, ...r }
|
|
32
|
+
}
|
|
33
|
+
if (driver === 'resend') {
|
|
34
|
+
const { sendByResend } = await import('./drivers/resend')
|
|
35
|
+
const r = await sendByResend(msg)
|
|
36
|
+
return { ok: true, ...r }
|
|
37
|
+
}
|
|
38
|
+
const { sendByConsole } = await import('./drivers/console')
|
|
39
|
+
const r = await sendByConsole(msg)
|
|
40
|
+
return { ok: true, ...r }
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { ok: false, messageId: null, bounced: false, error: err instanceof Error ? err.message : String(err) }
|
|
20
43
|
}
|
|
21
|
-
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Button } from '@react-email/components'
|
|
2
|
+
|
|
3
|
+
export function EmailButton({ href, label }: { href: string; label: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<Button
|
|
6
|
+
href={href}
|
|
7
|
+
className="bg-fg text-base text-fg-inverted inline-block rounded-lg px-7 py-4 text-center font-sans leading-6"
|
|
8
|
+
>
|
|
9
|
+
{label}
|
|
10
|
+
</Button>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Body, Container, Head, Heading, Html, Img, Link, Section, Tailwind, Text } from '@react-email/components'
|
|
3
|
+
import { allblueTailwindConfig } from './theme'
|
|
4
|
+
import { AllblueFonts } from './theme-fonts'
|
|
5
|
+
import type { EmailLocale } from '../../registry'
|
|
6
|
+
|
|
7
|
+
const LOGO_URL = `${process.env.VITE_APP_URL ?? 'https://allblue.local'}/logo/allblue-logo-light.png`
|
|
8
|
+
|
|
9
|
+
const SLOGAN: Record<EmailLocale, string> = {
|
|
10
|
+
zh: 'AllBlue · 理想之海 — 让每一个想法被看见。',
|
|
11
|
+
en: 'AllBlue — Where every idea finds its sea.',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TRANSACTIONAL_FOOTER: Record<EmailLocale, string> = {
|
|
15
|
+
zh: '这是一封事务性邮件,您之所以收到它是因为您在 AllBlue 有账号操作。',
|
|
16
|
+
en: 'You received this transactional email because of an action on your AllBlue account.',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const UNSUBSCRIBE_LABEL: Record<EmailLocale, string> = {
|
|
20
|
+
zh: '退订',
|
|
21
|
+
en: 'Unsubscribe',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const UNSUBSCRIBE_HINT: Record<EmailLocale, string> = {
|
|
25
|
+
zh: '不想再收到这类邮件?',
|
|
26
|
+
en: "Don't want these emails?",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NOTIFICATION_FOOTER: Record<EmailLocale, string> = {
|
|
30
|
+
zh: '这是 AllBlue 的通知邮件,可通过下方链接退订。',
|
|
31
|
+
en: 'This is a notification from AllBlue. Unsubscribe via the link below.',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EmailLayoutProps {
|
|
35
|
+
locale: EmailLocale
|
|
36
|
+
children: ReactNode
|
|
37
|
+
preview?: string
|
|
38
|
+
heading?: string
|
|
39
|
+
unsubscribeUrl?: string
|
|
40
|
+
footerKind?: 'transactional' | 'notification'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function EmailLayout({
|
|
44
|
+
locale,
|
|
45
|
+
children,
|
|
46
|
+
preview,
|
|
47
|
+
heading,
|
|
48
|
+
unsubscribeUrl,
|
|
49
|
+
footerKind = 'transactional',
|
|
50
|
+
}: EmailLayoutProps) {
|
|
51
|
+
const footer =
|
|
52
|
+
footerKind === 'notification'
|
|
53
|
+
? NOTIFICATION_FOOTER[locale]
|
|
54
|
+
: TRANSACTIONAL_FOOTER[locale]
|
|
55
|
+
return (
|
|
56
|
+
<Tailwind config={allblueTailwindConfig}>
|
|
57
|
+
<Html>
|
|
58
|
+
<Head>
|
|
59
|
+
<AllblueFonts />
|
|
60
|
+
</Head>
|
|
61
|
+
<Body className="bg-bg-2 m-0 font-sans text-center">
|
|
62
|
+
<Container className="mobile:mt-0 mx-auto mt-8 w-full max-w-[640px]">
|
|
63
|
+
<Section>
|
|
64
|
+
<Section className="bg-bg mobile:px-2 px-6 py-4">
|
|
65
|
+
<Section className="mb-3 px-6 text-left">
|
|
66
|
+
<Img
|
|
67
|
+
src={LOGO_URL}
|
|
68
|
+
alt="AllBlue"
|
|
69
|
+
width={32}
|
|
70
|
+
className="inline-block align-middle"
|
|
71
|
+
/>
|
|
72
|
+
</Section>
|
|
73
|
+
|
|
74
|
+
<Section className="bg-bg-2 mobile:px-6 mobile:py-12 rounded-[8px] px-[40px] py-[64px] text-center">
|
|
75
|
+
<Section className="mb-6">
|
|
76
|
+
<Img
|
|
77
|
+
src={LOGO_URL}
|
|
78
|
+
alt="AllBlue"
|
|
79
|
+
width={48}
|
|
80
|
+
className="mx-auto mb-5 block"
|
|
81
|
+
/>
|
|
82
|
+
</Section>
|
|
83
|
+
{heading ? (
|
|
84
|
+
<Heading as="h1" className="text-3xl text-fg m-0 mb-6 font-sans">
|
|
85
|
+
{heading}
|
|
86
|
+
</Heading>
|
|
87
|
+
) : null}
|
|
88
|
+
{children}
|
|
89
|
+
</Section>
|
|
90
|
+
|
|
91
|
+
{/* Footer — 无办公地址 */}
|
|
92
|
+
<Section className="bg-bg">
|
|
93
|
+
<Section className="px-6 py-10 text-center">
|
|
94
|
+
<Text className="text-sm text-fg-3 mx-auto mt-0 mb-8 max-w-[280px] text-center font-sans">
|
|
95
|
+
{SLOGAN[locale]}
|
|
96
|
+
</Text>
|
|
97
|
+
<Text className="text-xs text-fg-3 mt-4 mb-5 text-center font-sans">
|
|
98
|
+
{footer}
|
|
99
|
+
</Text>
|
|
100
|
+
{unsubscribeUrl ? (
|
|
101
|
+
<Text className="text-xs text-fg-3 m-0 text-center font-sans">
|
|
102
|
+
{UNSUBSCRIBE_HINT[locale]}{' '}
|
|
103
|
+
<Link href={unsubscribeUrl} className="text-fg-3 underline">
|
|
104
|
+
{UNSUBSCRIBE_LABEL[locale]}
|
|
105
|
+
</Link>
|
|
106
|
+
</Text>
|
|
107
|
+
) : null}
|
|
108
|
+
</Section>
|
|
109
|
+
</Section>
|
|
110
|
+
</Section>
|
|
111
|
+
</Section>
|
|
112
|
+
</Container>
|
|
113
|
+
</Body>
|
|
114
|
+
</Html>
|
|
115
|
+
</Tailwind>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Font } from '@react-email/components'
|
|
2
|
+
|
|
3
|
+
export function AllblueFonts() {
|
|
4
|
+
return (
|
|
5
|
+
<>
|
|
6
|
+
<style
|
|
7
|
+
dangerouslySetInnerHTML={{
|
|
8
|
+
__html: `@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');`,
|
|
9
|
+
}}
|
|
10
|
+
/>
|
|
11
|
+
<Font
|
|
12
|
+
fontFamily="Inter"
|
|
13
|
+
fallbackFontFamily={['Arial', 'sans-serif']}
|
|
14
|
+
webFont={{
|
|
15
|
+
url: 'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuOKfMZg.ttf',
|
|
16
|
+
format: 'truetype',
|
|
17
|
+
}}
|
|
18
|
+
fontWeight={400}
|
|
19
|
+
fontStyle="normal"
|
|
20
|
+
/>
|
|
21
|
+
<Font
|
|
22
|
+
fontFamily="Inter"
|
|
23
|
+
fallbackFontFamily={['Arial', 'sans-serif']}
|
|
24
|
+
webFont={{
|
|
25
|
+
url: 'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf',
|
|
26
|
+
format: 'truetype',
|
|
27
|
+
}}
|
|
28
|
+
fontWeight={500}
|
|
29
|
+
fontStyle="normal"
|
|
30
|
+
/>
|
|
31
|
+
<Font
|
|
32
|
+
fontFamily="Inter"
|
|
33
|
+
fallbackFontFamily={['Arial', 'sans-serif']}
|
|
34
|
+
webFont={{
|
|
35
|
+
url: 'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf',
|
|
36
|
+
format: 'truetype',
|
|
37
|
+
}}
|
|
38
|
+
fontWeight={600}
|
|
39
|
+
fontStyle="normal"
|
|
40
|
+
/>
|
|
41
|
+
</>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TailwindConfig } from '@react-email/components'
|
|
2
|
+
|
|
3
|
+
export const allblueTailwindConfig: TailwindConfig = {
|
|
4
|
+
theme: {
|
|
5
|
+
extend: {
|
|
6
|
+
colors: {
|
|
7
|
+
bg: '#FFFFFF',
|
|
8
|
+
'bg-2': '#F3F4F6',
|
|
9
|
+
fg: '#14171E',
|
|
10
|
+
'fg-2': '#43454B',
|
|
11
|
+
'fg-3': '#7B7D81',
|
|
12
|
+
'fg-inverted': '#FFFFFF',
|
|
13
|
+
stroke: '#F0F0F0',
|
|
14
|
+
'stroke-strong': '#E4E4E7',
|
|
15
|
+
brand: '#2563eb',
|
|
16
|
+
},
|
|
17
|
+
fontFamily: {
|
|
18
|
+
sans: ['Inter', 'system-ui', 'Arial', 'sans-serif'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Section, Text } from '@react-email/components'
|
|
2
|
+
import { EmailLayout } from '../_base/email-layout'
|
|
3
|
+
import { EmailButton } from '../_base/button'
|
|
4
|
+
import { registerTemplate, type EmailLocale } from '../../registry'
|
|
5
|
+
|
|
6
|
+
interface ResetPasswordProps {
|
|
7
|
+
locale: EmailLocale
|
|
8
|
+
url: string
|
|
9
|
+
unsubscribeUrl?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const T = {
|
|
13
|
+
zh: {
|
|
14
|
+
subject: '重置你的密码',
|
|
15
|
+
preview: '重置你的 AllBlue 密码',
|
|
16
|
+
heading: '重置密码',
|
|
17
|
+
greeting: '你好:',
|
|
18
|
+
body: '我们收到了重置你 AllBlue 账户密码的请求。请点击下方按钮设置新密码(链接 15 分钟内有效,且只能使用一次)。如果按钮无法点击,请复制以下链接到浏览器打开:',
|
|
19
|
+
cta: '重置密码',
|
|
20
|
+
ignore: '如果这不是你发起的请求,你的密码仍然安全,无需任何操作。',
|
|
21
|
+
urlLabel: '链接:',
|
|
22
|
+
},
|
|
23
|
+
en: {
|
|
24
|
+
subject: 'Reset your password',
|
|
25
|
+
preview: 'Reset your AllBlue password',
|
|
26
|
+
heading: 'Reset password',
|
|
27
|
+
greeting: 'Hello,',
|
|
28
|
+
body: 'We received a request to reset your AllBlue password. Click the button below to set a new one (the link is valid for 15 minutes and can be used only once). If the button does not work, copy this link into your browser:',
|
|
29
|
+
cta: 'Reset password',
|
|
30
|
+
ignore: 'If you did not request this, your password is safe — no action is needed.',
|
|
31
|
+
urlLabel: 'Link:',
|
|
32
|
+
},
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
function ResetPasswordEmail({ locale, url, unsubscribeUrl }: ResetPasswordProps) {
|
|
36
|
+
const t = T[locale]
|
|
37
|
+
return (
|
|
38
|
+
<EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
|
|
39
|
+
<Text className="text-base text-fg-2 mt-0 mb-6 font-sans">{t.greeting}</Text>
|
|
40
|
+
<Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body}</Text>
|
|
41
|
+
<Section className="mb-6 text-center">
|
|
42
|
+
<EmailButton href={url} label={t.cta} />
|
|
43
|
+
</Section>
|
|
44
|
+
<Text className="text-sm text-fg-3 break-all font-sans">{t.urlLabel} {url}</Text>
|
|
45
|
+
<Text className="text-sm text-fg-3 mt-8 font-sans">{t.ignore}</Text>
|
|
46
|
+
</EmailLayout>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerResetPasswordTemplate() {
|
|
51
|
+
registerTemplate({
|
|
52
|
+
key: 'auth.reset-password',
|
|
53
|
+
category: 'auth',
|
|
54
|
+
transactional: true,
|
|
55
|
+
subject: { zh: T.zh.subject, en: T.en.subject },
|
|
56
|
+
component: ResetPasswordEmail,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Link, Section, Text } from '@react-email/components'
|
|
2
|
+
import { EmailLayout } from '../_base/email-layout'
|
|
3
|
+
import { EmailButton } from '../_base/button'
|
|
4
|
+
import { registerTemplate, type EmailLocale } from '../../registry'
|
|
5
|
+
|
|
6
|
+
interface VerifyEmailProps {
|
|
7
|
+
locale: EmailLocale
|
|
8
|
+
url: string
|
|
9
|
+
username: string
|
|
10
|
+
unsubscribeUrl?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const T = {
|
|
14
|
+
zh: {
|
|
15
|
+
subject: '验证您的邮箱',
|
|
16
|
+
preview: '验证您的 AllBlue 邮箱地址',
|
|
17
|
+
heading: '我们快完成了!',
|
|
18
|
+
greeting: (u: string) => `你好,${u}:`,
|
|
19
|
+
body: '感谢注册 AllBlue。请点击下方按钮验证您的邮箱地址(24 小时内有效)。如果按钮无法点击,请复制以下链接到浏览器打开:',
|
|
20
|
+
cta: '验证邮箱',
|
|
21
|
+
ignore: '如果这不是你的操作,请忽略本邮件。',
|
|
22
|
+
urlLabel: '链接:',
|
|
23
|
+
},
|
|
24
|
+
en: {
|
|
25
|
+
subject: 'Verify your email',
|
|
26
|
+
preview: 'Verify your AllBlue email address',
|
|
27
|
+
heading: "We're almost there!",
|
|
28
|
+
greeting: (u: string) => `Hi ${u},`,
|
|
29
|
+
body: 'Thank you for signing up for AllBlue. Click the button below to verify your email address (link valid for 24 hours). If the button does not work, copy this link into your browser:',
|
|
30
|
+
cta: 'Verify email',
|
|
31
|
+
ignore: 'If this was not you, please ignore this email.',
|
|
32
|
+
urlLabel: 'Link:',
|
|
33
|
+
},
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
function VerifyEmailEmail({ locale, url, username, unsubscribeUrl }: VerifyEmailProps) {
|
|
37
|
+
const t = T[locale]
|
|
38
|
+
return (
|
|
39
|
+
<EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
|
|
40
|
+
<Text className="text-base text-fg-2 mt-0 mb-6 font-sans">{t.greeting(username)}</Text>
|
|
41
|
+
<Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body}</Text>
|
|
42
|
+
<Section className="mb-6 text-center">
|
|
43
|
+
<EmailButton href={url} label={t.cta} />
|
|
44
|
+
</Section>
|
|
45
|
+
<Text className="text-sm text-fg-3 break-all font-sans">{t.urlLabel} {url}</Text>
|
|
46
|
+
<Text className="text-sm text-fg-3 mt-8 font-sans">{t.ignore}</Text>
|
|
47
|
+
</EmailLayout>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerVerifyEmailTemplate() {
|
|
52
|
+
registerTemplate({
|
|
53
|
+
key: 'auth.verify-email',
|
|
54
|
+
category: 'auth',
|
|
55
|
+
transactional: true,
|
|
56
|
+
subject: { zh: T.zh.subject, en: T.en.subject },
|
|
57
|
+
component: VerifyEmailEmail,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Section, Text } from '@react-email/components'
|
|
2
|
+
import { EmailLayout } from '../_base/email-layout'
|
|
3
|
+
import { EmailButton } from '../_base/button'
|
|
4
|
+
import { registerTemplate, type EmailLocale } from '../../registry'
|
|
5
|
+
|
|
6
|
+
interface WelcomeProps {
|
|
7
|
+
locale: EmailLocale
|
|
8
|
+
dashboardUrl: string
|
|
9
|
+
unsubscribeUrl?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const T = {
|
|
13
|
+
zh: {
|
|
14
|
+
subject: '邮箱验证成功,欢迎加入 AllBlue',
|
|
15
|
+
preview: '欢迎加入 AllBlue',
|
|
16
|
+
heading: '欢迎加入 AllBlue',
|
|
17
|
+
body: '你的邮箱已验证成功。现在可以完整使用 AllBlue 的全部功能了。',
|
|
18
|
+
cta: '进入工作台',
|
|
19
|
+
},
|
|
20
|
+
en: {
|
|
21
|
+
subject: 'Email verified — welcome to AllBlue',
|
|
22
|
+
preview: 'Welcome to AllBlue',
|
|
23
|
+
heading: 'Welcome to AllBlue',
|
|
24
|
+
body: 'Your email has been verified. You now have full access to AllBlue.',
|
|
25
|
+
cta: 'Go to dashboard',
|
|
26
|
+
},
|
|
27
|
+
} as const
|
|
28
|
+
|
|
29
|
+
function WelcomeEmail({ locale, dashboardUrl, unsubscribeUrl }: WelcomeProps) {
|
|
30
|
+
const t = T[locale]
|
|
31
|
+
return (
|
|
32
|
+
<EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
|
|
33
|
+
<Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body}</Text>
|
|
34
|
+
<Section className="mb-6 text-center">
|
|
35
|
+
<EmailButton href={dashboardUrl} label={t.cta} />
|
|
36
|
+
</Section>
|
|
37
|
+
</EmailLayout>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerWelcomeTemplate() {
|
|
42
|
+
registerTemplate({
|
|
43
|
+
key: 'auth.welcome',
|
|
44
|
+
category: 'auth',
|
|
45
|
+
transactional: true,
|
|
46
|
+
subject: { zh: T.zh.subject, en: T.en.subject },
|
|
47
|
+
component: WelcomeEmail,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Section, Text } from '@react-email/components'
|
|
2
|
+
import { EmailLayout } from '../_base/email-layout'
|
|
3
|
+
import { EmailButton } from '../_base/button'
|
|
4
|
+
import { registerTemplate, type EmailLocale } from '../../registry'
|
|
5
|
+
|
|
6
|
+
interface PaymentReceiptProps {
|
|
7
|
+
locale: EmailLocale
|
|
8
|
+
planName: string
|
|
9
|
+
periodEnd: string
|
|
10
|
+
dashboardUrl: string
|
|
11
|
+
unsubscribeUrl?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const T = {
|
|
15
|
+
zh: {
|
|
16
|
+
subject: '支付成功——AllBlue 订阅',
|
|
17
|
+
preview: '你的支付已成功',
|
|
18
|
+
heading: '支付成功',
|
|
19
|
+
body: (plan: string, end: string) => `你的 AllBlue「${plan}」订阅支付成功,当前周期至 ${end}。`,
|
|
20
|
+
cta: '查看订阅',
|
|
21
|
+
},
|
|
22
|
+
en: {
|
|
23
|
+
subject: 'Payment received — AllBlue subscription',
|
|
24
|
+
preview: 'Your payment was successful',
|
|
25
|
+
heading: 'Payment received',
|
|
26
|
+
body: (plan: string, end: string) => `Your AllBlue "${plan}" subscription payment was successful. Current period ends ${end}.`,
|
|
27
|
+
cta: 'View subscription',
|
|
28
|
+
},
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
function PaymentReceiptEmail({ locale, planName, periodEnd, dashboardUrl, unsubscribeUrl }: PaymentReceiptProps) {
|
|
32
|
+
const t = T[locale]
|
|
33
|
+
return (
|
|
34
|
+
<EmailLayout locale={locale} preview={t.preview} heading={t.heading} unsubscribeUrl={unsubscribeUrl} footerKind="transactional">
|
|
35
|
+
<Text className="text-base text-fg-2 mx-auto mt-0 mb-8 max-w-[380px] font-sans">{t.body(planName, periodEnd)}</Text>
|
|
36
|
+
<Section className="mb-6 text-center">
|
|
37
|
+
<EmailButton href={dashboardUrl} label={t.cta} />
|
|
38
|
+
</Section>
|
|
39
|
+
</EmailLayout>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerPaymentReceiptTemplate() {
|
|
44
|
+
registerTemplate({
|
|
45
|
+
key: 'billing.payment-receipt',
|
|
46
|
+
category: 'billing',
|
|
47
|
+
transactional: true,
|
|
48
|
+
subject: { zh: T.zh.subject, en: T.en.subject },
|
|
49
|
+
component: PaymentReceiptEmail,
|
|
50
|
+
})
|
|
51
|
+
}
|