@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.
- package/package.json +6 -3
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-light.png +0 -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/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/forms/app-field.tsx +3 -1
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +4 -4
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/session.ts +2 -2
- 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 +14 -1
- package/src/routes/__tests__/route-tree-structure.test.ts +1 -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/route.tsx +5 -0
- 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__/auth-registration.test.ts +60 -0
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -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 +34 -17
- 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/middlewares/security-headers.ts +11 -2
- 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__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +2 -2
- 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/notifications.ts +10 -0
- package/src/server/serverFns/team.ts +49 -11
- 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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|