@allbluecn/web-app 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/package.json +6 -3
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-dark.png +0 -0
  6. package/public/logo/allblue-logo-light.png +0 -0
  7. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  8. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  9. package/src/components/auth/icon-input.tsx +9 -1
  10. package/src/components/auth/password-input.tsx +9 -1
  11. package/src/components/changelog/changelog-page.tsx +44 -1
  12. package/src/components/forms/app-field.tsx +3 -1
  13. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  14. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  15. package/src/components/notifications/settings-notifications.tsx +41 -1
  16. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  17. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  18. package/src/lib/app-version.ts +1 -1
  19. package/src/lib/auth/auth.config.ts +123 -99
  20. package/src/lib/changelog/sdk-versions.ts +4 -4
  21. package/src/lib/edition-desktop.ts +22 -0
  22. package/src/lib/session.ts +2 -2
  23. package/src/routeTree.gen.ts +114 -0
  24. package/src/routes/__root.tsx +20 -3
  25. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  26. package/src/routes/__tests__/-edition-routing.test.tsx +14 -1
  27. package/src/routes/__tests__/route-tree-structure.test.ts +1 -0
  28. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  29. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  30. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  31. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  32. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  33. package/src/routes/_login/login/route.lazy.tsx +4 -2
  34. package/src/routes/_login/register/route.lazy.tsx +28 -17
  35. package/src/routes/_login/register/route.tsx +8 -1
  36. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  37. package/src/routes/_login/schemas/-auth.ts +30 -7
  38. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  39. package/src/routes/_login/verify-email/route.tsx +28 -0
  40. package/src/routes/_nav/route.tsx +5 -0
  41. package/src/routes/_public/index/route.tsx +2 -1
  42. package/src/routes/api/auth/desktop-callback.ts +2 -2
  43. package/src/routes/api/billing-webhook.ts +4 -0
  44. package/src/routes/api/cron/changelog-release.ts +100 -0
  45. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  46. package/src/routes/api.auth.$.ts +3 -5
  47. package/src/server/__tests__/auth-registration.test.ts +60 -0
  48. package/src/server/auth-registration.ts +40 -0
  49. package/src/server/billing-email.ts +39 -0
  50. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  51. package/src/server/email/__tests__/index.test.ts +32 -0
  52. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  53. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  54. package/src/server/email/__tests__/registry.test.tsx +50 -0
  55. package/src/server/email/deliverability.ts +57 -0
  56. package/src/server/email/drivers/console.ts +11 -0
  57. package/src/server/email/drivers/resend.ts +31 -0
  58. package/src/server/email/drivers/smtp.ts +27 -0
  59. package/src/server/email/index.ts +56 -0
  60. package/src/server/email/outbox.ts +141 -0
  61. package/src/server/email/preferences.ts +20 -0
  62. package/src/server/email/registry.ts +38 -0
  63. package/src/server/email/sender.ts +34 -17
  64. package/src/server/email/templates/_base/button.tsx +12 -0
  65. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  66. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  67. package/src/server/email/templates/_base/theme.ts +22 -0
  68. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  69. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  70. package/src/server/email/templates/auth/welcome.tsx +49 -0
  71. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  72. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  73. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  74. package/src/server/email/templates/notification/mirror.tsx +50 -0
  75. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  76. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  77. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  78. package/src/server/email/templates/team/approved.tsx +53 -0
  79. package/src/server/email/templates/team/invite.tsx +57 -0
  80. package/src/server/middlewares/security-headers.ts +11 -2
  81. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  82. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  83. package/src/server/notifications/notification-service.ts +71 -1
  84. package/src/server/notifications/verify-reminder.ts +55 -0
  85. package/src/server/oauth-config.ts +59 -0
  86. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  87. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  88. package/src/server/serverFns/__tests__/team-invitation.test.ts +2 -2
  89. package/src/server/serverFns/auth/email-verification.ts +132 -0
  90. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  91. package/src/server/serverFns/auth/providers.ts +4 -3
  92. package/src/server/serverFns/auth/register.ts +63 -19
  93. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  94. package/src/server/serverFns/auth/reset-password.ts +1 -1
  95. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  96. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  97. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  98. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  99. package/src/server/serverFns/notifications.ts +10 -0
  100. package/src/server/serverFns/team.ts +49 -11
  101. package/vite.shared.ts +87 -3
@@ -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 }
@@ -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,27 +1,44 @@
1
- export interface EmailParams {
1
+ export interface EmailMessage {
2
2
  to: string
3
3
  subject: string
4
- html?: string
4
+ html: string
5
5
  text?: string
6
+ headers?: Record<string, string>
7
+ replyTo?: string
6
8
  }
7
9
 
8
- export interface EmailSender {
9
- send(params: EmailParams): Promise<void>
10
+ export interface SendResult {
11
+ ok: boolean
12
+ messageId: string | null
13
+ bounced: boolean
14
+ error?: string
10
15
  }
11
16
 
12
- // 遮蔽邮箱本地部分,仅保留域名用于排查
13
- function maskEmail(address: string): string {
14
- const [local, domain] = address.split("@")
15
- return domain && local ? `${local.slice(0, 1)}***@${domain}` : "***"
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'
16
23
  }
17
24
 
18
- export function getEmailSender(): EmailSender {
19
- return {
20
- send: async (params) => {
21
- console.log(`[email] To: ${maskEmail(params.to)}, Subject: ${params.subject}`)
22
- if (params.text) {
23
- console.log(`[email] Text: ${params.text.length} chars`)
24
- }
25
- },
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) }
26
43
  }
27
- }
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
+ }