feat(auth): add configurable email verification

This commit is contained in:
saltbo
2026-07-22 10:15:29 -04:00
parent e4750f84ce
commit df0c5f3fbd
16 changed files with 450 additions and 33 deletions
+1
View File
@@ -9,6 +9,7 @@ async function saveEmailConfig(page: import('@playwright/test').Page) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
requireEmailVerification: false,
provider: 'http',
from: 'no-reply@example.com',
http: {
+105
View File
@@ -20,6 +20,17 @@ async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknow
})
}
async function configureRequiredEmailVerification(ctx: TestCtx) {
await ctx.db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'http' },
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
{ key: 'email_http_api_key', value: 'my-api-key' },
{ key: 'auth_require_email_verification', value: 'true' },
])
}
async function expectPlanEntitlement(ctx: TestCtx, resourceType: 'storage' | 'traffic', bytes: number) {
const rows = await ctx.db.select().from(schema.orgQuotaEntitlements)
expect(rows).toEqual(
@@ -301,6 +312,100 @@ describe('isEmailConfigured — via emailVerification conditional', () => {
})
})
describe('dynamic email verification policy', () => {
it('requires verification immediately and resends the email on sign-in', async () => {
const { vi } = await import('vitest')
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
try {
const ctx = await createTestApp()
await configureRequiredEmailVerification(ctx)
const signUpResponse = await signUp(ctx, 'required@example.com', { username: 'required_user' })
expect(signUpResponse.status).toBe(200)
await expect(signUpResponse.json()).resolves.toMatchObject({ token: null })
expect(await ctx.db.select().from(authSchema.session)).toHaveLength(0)
expect(fetchMock).toHaveBeenCalledTimes(1)
const signInResponse = await ctx.app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'required@example.com', password: 'password123456' }),
})
expect(signInResponse.status).toBe(403)
await expect(signInResponse.json()).resolves.toMatchObject({ code: 'EMAIL_NOT_VERIFIED' })
expect(fetchMock).toHaveBeenCalledTimes(2)
const usernameSignInResponse = await ctx.app.request('/api/auth/sign-in/username', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'required_user', password: 'password123456' }),
})
expect(usernameSignInResponse.status).toBe(403)
await expect(usernameSignInResponse.json()).resolves.toMatchObject({ code: 'EMAIL_NOT_VERIFIED' })
expect(fetchMock).toHaveBeenCalledTimes(3)
} finally {
vi.unstubAllGlobals()
}
})
it('accepts the verification link and marks the user as verified', async () => {
const { vi } = await import('vitest')
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
try {
const ctx = await createTestApp()
await configureRequiredEmailVerification(ctx)
await signUp(ctx, 'verify-required@example.com')
const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
const payload = JSON.parse(String(request?.body)) as { html: string }
const verificationUrl = payload.html.match(/href="([^"]+)"/)?.[1]
if (!verificationUrl) throw new Error('Verification email did not contain a link')
const url = new URL(verificationUrl)
const verifyResponse = await ctx.app.request(`${url.pathname}${url.search}`)
expect(verifyResponse.status).toBe(302)
const [user] = await ctx.db
.select({ emailVerified: authSchema.user.emailVerified })
.from(authSchema.user)
.where(eq(authSchema.user.email, 'verify-required@example.com'))
.limit(1)
expect(user.emailVerified).toBe(true)
} finally {
vi.unstubAllGlobals()
}
})
it('applies a disabled policy without recreating the auth service', async () => {
const { vi } = await import('vitest')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
try {
const ctx = await createTestApp()
await configureRequiredEmailVerification(ctx)
await signUp(ctx, 'toggle@example.com')
await ctx.db
.update(schema.systemOptions)
.set({ value: 'false' })
.where(eq(schema.systemOptions.key, 'auth_require_email_verification'))
const signInResponse = await ctx.app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'toggle@example.com', password: 'password123456' }),
})
expect(signInResponse.status).toBe(200)
expect(signInResponse.headers.getSetCookie()).not.toHaveLength(0)
} finally {
vi.unstubAllGlobals()
}
})
})
describe('buildVerificationEmailHtml — via send-verification-email with email_provider configured', () => {
it('send-verification-email triggers email send when email_provider is configured', async () => {
const { vi } = await import('vitest')
+56 -11
View File
@@ -1,5 +1,5 @@
import { apiKey } from '@better-auth/api-key'
import { APIError, type BetterAuthPlugin, betterAuth } from 'better-auth'
import { APIError, type BetterAuthOptions, type BetterAuthPlugin, betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
import type { CaptchaOptions } from 'better-auth/plugins'
@@ -39,6 +39,7 @@ import * as authSchema from './db/auth-schema'
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
import { executeWriteTransaction } from './db/transaction'
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig } from './domain/captcha'
import { EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, isEmailVerificationRequired } from './domain/email-verification'
import { currentTrafficPeriod } from './domain/quota'
import { recordAuditEffect } from './lib/audit'
import { isLocalNetworkOrigin } from './lib/local-origin'
@@ -130,6 +131,13 @@ function dynamicCaptcha(db: Database): BetterAuthPlugin {
}
}
const EMAIL_VERIFICATION_AUTH_PATHS = ['/sign-up/email', '/sign-in/email', '/sign-in/username'] as const
function usesEmailVerificationPolicy(request: Request): boolean {
const path = new URL(request.url).pathname
return EMAIL_VERIFICATION_AUTH_PATHS.some((authPath) => path.endsWith(authPath))
}
const _INVITE_CODE_ERRORS: Record<string, string> = {
not_found: 'Invalid invite code',
already_used: 'Invite code already used',
@@ -303,9 +311,10 @@ export async function createAuth(
// db proxy in a binding-free Platform — matching the previous behaviour where
// a Database source had no CF binding available.
const authPlatform: Platform = platformProxy ?? { db: dbProxy, getEnv: () => undefined, getBinding: () => undefined }
const email = createEmailGateway(createSystemOptionsRepo(db))
const systemOptionsRepo = createSystemOptionsRepo(db)
const email = createEmailGateway(systemOptionsRepo)
const providerConfigs = await loadProviderConfigs(rawDb)
const auth = betterAuth({
const authOptions = {
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
secret,
baseURL,
@@ -668,15 +677,51 @@ export async function createAuth(
},
},
},
})
} satisfies BetterAuthOptions
// betterAuth() starts its lazy $context init synchronously, inside whichever
// request constructs the instance. Resolve it here so a cached instance never
// carries a pending promise tied to its creating request — on Cloudflare
// Workers such a promise never settles when awaited from a later request,
// which would hang every auth call in the isolate.
await auth.$context
return auth
const buildAuth = (requireEmailVerification: boolean) =>
betterAuth({
...authOptions,
emailAndPassword: { ...authOptions.emailAndPassword, requireEmailVerification },
emailVerification: {
...authOptions.emailVerification,
sendOnSignUp: requireEmailVerification,
sendOnSignIn: requireEmailVerification,
},
})
const createAuthInstance = async (requireEmailVerification: boolean) => {
const auth = buildAuth(requireEmailVerification)
// betterAuth() starts its lazy $context init synchronously, inside whichever
// request constructs the instance. Resolve it here so a cached instance never
// carries a pending promise tied to its creating request — on Cloudflare
// Workers such a promise never settles when awaited from a later request,
// which would hang every auth call in the isolate.
await auth.$context
return auth
}
const defaultAuth = await createAuthInstance(false)
let verificationAuth: typeof defaultAuth | null = null
const dynamicHandler = async (request: Request): Promise<Response> => {
if (!usesEmailVerificationPolicy(request)) return defaultAuth.handler(request)
const required = isEmailVerificationRequired(
await systemOptionsRepo.getValue(EMAIL_VERIFICATION_REQUIRED_OPTION_KEY),
)
if (!required) return defaultAuth.handler(request)
verificationAuth ??= await createAuthInstance(true)
return verificationAuth.handler(request)
}
return new Proxy(defaultAuth, {
get(target, property) {
if (property === 'handler') return dynamicHandler
return Reflect.get(target, property, target)
},
})
}
export type Auth = Awaited<ReturnType<typeof createAuth>>
+5
View File
@@ -0,0 +1,5 @@
export const EMAIL_VERIFICATION_REQUIRED_OPTION_KEY = 'auth_require_email_verification'
export function isEmailVerificationRequired(value: string | null): boolean {
return value === 'true'
}
@@ -1,3 +1,4 @@
import { eq } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as schema from '../../db/schema.js'
import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
@@ -82,7 +83,7 @@ describe('Admin Email Config API — GET', () => {
const res = await app.request('/api/site/email', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body).toEqual({ enabled: false, provider: null })
expect(body).toEqual({ enabled: false, requireEmailVerification: false, provider: null })
})
it('returns enabled with null provider when email is enabled but sender/provider are incomplete [spec: email-config/incomplete-provider]', async () => {
@@ -94,6 +95,7 @@ describe('Admin Email Config API — GET', () => {
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
enabled: true,
requireEmailVerification: false,
provider: null,
})
})
@@ -152,6 +154,7 @@ describe('Admin Email Config API — GET', () => {
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
enabled: true,
requireEmailVerification: false,
provider: null,
})
})
@@ -168,6 +171,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: true,
from: 'no-reply@example.com',
smtp: {
host: 'smtp.example.com',
@@ -193,6 +197,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: false,
from: 'sender@example.com',
smtp: {
host: 'mail.example.com',
@@ -213,6 +218,34 @@ describe('Admin Email Config API — PUT', () => {
expect(smtp.port).toBe(465)
})
it('preserves the SMTP password when only settings around it are changed', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedSmtpConfig(db)
const currentResponse = await app.request('/api/site/email', { headers })
const current = (await currentResponse.json()) as {
smtp: { host: string; port: number; user: string; pass: string; secure: boolean }
}
await app.request('/api/site/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: true,
from: 'no-reply@example.com',
smtp: current.smtp,
}),
})
const [stored] = await db
.select({ value: schema.systemOptions.value })
.from(schema.systemOptions)
.where(eq(schema.systemOptions.key, 'email_smtp_pass'))
expect(stored.value).toBe('supersecret')
})
it('saves HTTP config and returns success [spec: email-config/save-http]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
@@ -223,6 +256,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'http',
enabled: true,
requireEmailVerification: false,
from: 'no-reply@example.com',
http: {
url: 'https://api.mail.example.com/send',
@@ -245,6 +279,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'http',
enabled: true,
requireEmailVerification: false,
from: 'http-from@example.com',
http: {
url: 'https://api.sendgrid.com/v3/mail/send',
@@ -285,6 +320,25 @@ describe('Admin Email Config API — PUT', () => {
expect(res.status).toBe(400)
})
it('rejects required verification when email delivery is disabled', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
requireEmailVerification: true,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true },
}),
})
expect(res.status).toBe(400)
})
it('updates existing config when PUT is called a second time [spec: email-config/update]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
@@ -295,6 +349,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: false,
from: 'first@example.com',
smtp: { host: 'first.smtp.com', port: 25, user: '', pass: '', secure: false },
}),
@@ -306,6 +361,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: false,
from: 'second@example.com',
smtp: { host: 'second.smtp.com', port: 587, user: '', pass: '', secure: true },
}),
@@ -329,6 +385,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'cloudflare',
enabled: true,
requireEmailVerification: false,
from: 'no-reply@zpan.space',
}),
})
@@ -346,6 +403,7 @@ describe('Admin Email Config API — PUT', () => {
body: JSON.stringify({
provider: 'cloudflare',
enabled: true,
requireEmailVerification: true,
from: 'no-reply@zpan.space',
}),
})
@@ -353,6 +411,7 @@ describe('Admin Email Config API — PUT', () => {
const res = await app.request('/api/site/email', { headers })
await expect(res.json()).resolves.toEqual({
enabled: true,
requireEmailVerification: true,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
@@ -367,6 +426,7 @@ describe('Admin Email Config API — PUT', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
requireEmailVerification: false,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true },
@@ -448,6 +508,7 @@ describe('Admin Email Config API — POST /test', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
requireEmailVerification: false,
provider: 'http',
from: 'no-reply@example.com',
http: { url: 'https://api.mail.example.com/send', apiKey: 'key' },
+13 -2
View File
@@ -6,6 +6,7 @@ import { errorResponse, jsonContent } from '../openapi'
const smtpConfigSchema = z.object({
enabled: z.boolean(),
requireEmailVerification: z.boolean(),
provider: z.literal('smtp'),
from: z.string().email(),
smtp: z.object({
@@ -19,6 +20,7 @@ const smtpConfigSchema = z.object({
const httpConfigSchema = z.object({
enabled: z.boolean(),
requireEmailVerification: z.boolean(),
provider: z.literal('http'),
from: z.string().email(),
http: z.object({ url: z.string().url(), apiKey: z.string().min(1) }),
@@ -26,6 +28,7 @@ const httpConfigSchema = z.object({
const cloudflareConfigSchema = z.object({
enabled: z.boolean(),
requireEmailVerification: z.boolean(),
provider: z.literal('cloudflare'),
from: z.string().email(),
})
@@ -36,7 +39,12 @@ const emailConfigSchema = z.discriminatedUnion('provider', [smtpConfigSchema, ht
// with the stable fields; provider-specific masked settings vary and are not
// modeled field-for-field (no `additionalProperties` — oapi-codegen mishandles it).
const emailSettingsSchema = z
.object({ enabled: z.boolean(), provider: z.string().nullable().optional(), from: z.string().optional() })
.object({
enabled: z.boolean(),
requireEmailVerification: z.boolean(),
provider: z.string().nullable().optional(),
from: z.string().optional(),
})
.openapi('EmailSettings')
const testEmailSchema = z.object({ to: z.string().email() })
@@ -61,7 +69,10 @@ const saveRoute = createRoute({
path: '/',
middleware: [requireAdmin] as const,
request: { body: { content: { 'application/json': { schema: emailConfigSchema } }, required: true } },
responses: { 200: jsonContent(successSchema, 'Saved') },
responses: {
200: jsonContent(successSchema, 'Saved'),
400: errorResponse('Invalid email configuration'),
},
})
const testRoute = createRoute({
+74 -3
View File
@@ -67,12 +67,20 @@ describe('email-config usecase', () => {
describe('getEmailConfig', () => {
it('returns the disabled empty state when no config exists', async () => {
const { deps } = makeDeps({ email: { getSettings: async () => ({ enabled: false, config: null }) } })
expect(await getEmailConfig(deps, platform)).toEqual({ enabled: false, provider: null })
expect(await getEmailConfig(deps, platform)).toEqual({
enabled: false,
requireEmailVerification: false,
provider: null,
})
})
it('returns enabled with provider null when config is incomplete', async () => {
const { deps } = makeDeps({ email: { getSettings: async () => ({ enabled: true, config: null }) } })
expect(await getEmailConfig(deps, platform)).toEqual({ enabled: true, provider: null })
expect(await getEmailConfig(deps, platform)).toEqual({
enabled: true,
requireEmailVerification: false,
provider: null,
})
})
it('masks the SMTP password, keeping the last 4 chars', async () => {
@@ -80,6 +88,7 @@ describe('email-config usecase', () => {
const out = await getEmailConfig(deps, platform)
expect(out).toEqual({
enabled: true,
requireEmailVerification: false,
provider: 'smtp',
from: 'no-reply@example.com',
smtp: {
@@ -97,6 +106,7 @@ describe('email-config usecase', () => {
const out = await getEmailConfig(deps, platform)
expect(out).toEqual({
enabled: true,
requireEmailVerification: false,
provider: 'http',
from: 'no-reply@example.com',
http: { url: 'https://api.mail.example.com/send', apiKey: '****-key' },
@@ -107,6 +117,7 @@ describe('email-config usecase', () => {
const { deps } = makeDeps({ email: { getSettings: async () => cloudflareSettings } })
expect(await getEmailConfig(deps, platform)).toEqual({
enabled: true,
requireEmailVerification: false,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
@@ -125,6 +136,14 @@ describe('email-config usecase', () => {
const out = (await getEmailConfig(deps, platform)) as unknown as { smtp: { pass: string } }
expect(out.smtp.pass).toBe('')
})
it('returns the stored email verification policy', async () => {
const { deps } = makeDeps({
email: { getSettings: async () => cloudflareSettings },
systemOptions: { getValue: async () => 'true' },
})
expect(await getEmailConfig(deps, platform)).toMatchObject({ requireEmailVerification: true })
})
})
describe('saveEmailConfig', () => {
@@ -132,6 +151,7 @@ describe('email-config usecase', () => {
const { deps, setMany } = makeDeps()
const input: SaveEmailConfigInput = {
enabled: true,
requireEmailVerification: true,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 465, user: 'u', pass: 'p', secure: false },
@@ -139,6 +159,7 @@ describe('email-config usecase', () => {
await saveEmailConfig(deps, input)
expect(setMany).toHaveBeenCalledWith([
{ key: 'email_enabled', value: 'true' },
{ key: 'auth_require_email_verification', value: 'true' },
{ key: 'email_provider', value: 'smtp' },
{ key: 'email_from', value: 'sender@example.com' },
{ key: 'email_smtp_host', value: 'mail.example.com' },
@@ -153,6 +174,7 @@ describe('email-config usecase', () => {
const { deps, setMany } = makeDeps()
const input: SaveEmailConfigInput = {
enabled: true,
requireEmailVerification: false,
provider: 'http',
from: 'http-from@example.com',
http: { url: 'https://api.sendgrid.com/v3/mail/send', apiKey: 'SG.key12345' },
@@ -160,6 +182,7 @@ describe('email-config usecase', () => {
await saveEmailConfig(deps, input)
expect(setMany).toHaveBeenCalledWith([
{ key: 'email_enabled', value: 'true' },
{ key: 'auth_require_email_verification', value: 'false' },
{ key: 'email_provider', value: 'http' },
{ key: 'email_from', value: 'http-from@example.com' },
{ key: 'email_http_url', value: 'https://api.sendgrid.com/v3/mail/send' },
@@ -167,12 +190,46 @@ describe('email-config usecase', () => {
])
})
it('preserves the stored SMTP password when the masked value is submitted unchanged', async () => {
const { deps, setMany } = makeDeps({ systemOptions: { getValue: async () => 'supersecret' } })
await saveEmailConfig(deps, {
enabled: true,
requireEmailVerification: true,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 587, user: 'u', pass: '****cret', secure: true },
})
expect(setMany).toHaveBeenCalledWith(expect.arrayContaining([{ key: 'email_smtp_pass', value: 'supersecret' }]))
})
it('preserves the stored HTTP API key when the masked value is submitted unchanged', async () => {
const { deps, setMany } = makeDeps({ systemOptions: { getValue: async () => 'my-secret-key' } })
await saveEmailConfig(deps, {
enabled: true,
requireEmailVerification: true,
provider: 'http',
from: 'sender@example.com',
http: { url: 'https://api.mail.example.com/send', apiKey: '****-key' },
})
expect(setMany).toHaveBeenCalledWith(
expect.arrayContaining([{ key: 'email_http_api_key', value: 'my-secret-key' }]),
)
})
it('writes only the three shared rows for Cloudflare', async () => {
const { deps, setMany } = makeDeps()
const input: SaveEmailConfigInput = { enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }
const input: SaveEmailConfigInput = {
enabled: true,
requireEmailVerification: false,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
}
await saveEmailConfig(deps, input)
expect(setMany).toHaveBeenCalledWith([
{ key: 'email_enabled', value: 'true' },
{ key: 'auth_require_email_verification', value: 'false' },
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
@@ -182,6 +239,7 @@ describe('email-config usecase', () => {
const { deps, setMany } = makeDeps()
const input: SaveEmailConfigInput = {
enabled: false,
requireEmailVerification: false,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true },
@@ -194,6 +252,19 @@ describe('email-config usecase', () => {
]),
)
})
it('rejects required verification when email sending is disabled', async () => {
const { deps, setMany } = makeDeps()
await expect(
saveEmailConfig(deps, {
enabled: false,
requireEmailVerification: true,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
}),
).rejects.toMatchObject({ httpStatus: 400 })
expect(setMany).not.toHaveBeenCalled()
})
})
describe('sendTestEmail', () => {
+39 -10
View File
@@ -9,6 +9,7 @@
// EmailGateway reads them back (and decides whether email is "configured").
// All reads/writes go through the ports — nothing here touches infrastructure.
import { EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, isEmailVerificationRequired } from '../../domain/email-verification'
import type { Platform } from '../../platform/interface'
import {
type AppError,
@@ -28,13 +29,22 @@ export type EmailConfigDeps = {
// The validated request body the http layer hands to saveEmailConfig — the same
// discriminated union the route's zod schema produces.
export type SaveEmailConfigInput =
| { enabled: boolean; provider: 'smtp'; from: string; smtp: SmtpConfig }
| { enabled: boolean; provider: 'http'; from: string; http: { url: string; apiKey: string } }
| { enabled: boolean; provider: 'cloudflare'; from: string }
| { enabled: boolean; requireEmailVerification: boolean; provider: 'smtp'; from: string; smtp: SmtpConfig }
| {
enabled: boolean
requireEmailVerification: boolean
provider: 'http'
from: string
http: { url: string; apiKey: string }
}
| { enabled: boolean; requireEmailVerification: boolean; provider: 'cloudflare'; from: string }
// The GET response shape: the enabled flag plus either a secret-masked view of
// the stored config or `{ provider: null }` when no usable config exists.
export type MaskedEmailSettings = { enabled: boolean } & (Record<string, unknown> | { provider: null })
export type MaskedEmailSettings = { enabled: boolean; requireEmailVerification: boolean } & (
| Record<string, unknown>
| { provider: null }
)
// A test send either succeeds or fails for a reportable reason (provider error,
// or email being disabled — the gateway throws for both). A failure becomes a 400
@@ -78,9 +88,14 @@ function maskConfig(config: EmailConfig): Record<string, unknown> {
// Flatten a validated config into the `email_*` system-option rows. The first
// three rows are written for every provider; provider-specific rows follow.
function configEntries(input: SaveEmailConfigInput): [string, string][] {
function preserveMaskedSecret(input: string, existing: string | null): string {
return existing !== null && input === maskSecret(existing) ? existing : input
}
function configEntries(input: SaveEmailConfigInput, existingSecret: string | null): [string, string][] {
const entries: [string, string][] = [
['email_enabled', String(input.enabled)],
[EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, String(input.requireEmailVerification)],
['email_provider', input.provider],
['email_from', input.from],
]
@@ -89,22 +104,29 @@ function configEntries(input: SaveEmailConfigInput): [string, string][] {
['email_smtp_host', input.smtp.host],
['email_smtp_port', String(input.smtp.port)],
['email_smtp_user', input.smtp.user],
['email_smtp_pass', input.smtp.pass],
['email_smtp_pass', preserveMaskedSecret(input.smtp.pass, existingSecret)],
['email_smtp_secure', String(input.smtp.secure)],
)
} else if (input.provider === 'http') {
entries.push(['email_http_url', input.http.url], ['email_http_api_key', input.http.apiKey])
entries.push(
['email_http_url', input.http.url],
['email_http_api_key', preserveMaskedSecret(input.http.apiKey, existingSecret)],
)
}
return entries
}
export async function getEmailConfig(
deps: Pick<EmailConfigDeps, 'email'>,
deps: Pick<EmailConfigDeps, 'email' | 'systemOptions'>,
platform: Platform,
): Promise<MaskedEmailSettings> {
const settings = await deps.email.getSettings(platform)
const [settings, requiredValue] = await Promise.all([
deps.email.getSettings(platform),
deps.systemOptions.getValue(EMAIL_VERIFICATION_REQUIRED_OPTION_KEY),
])
return {
enabled: settings.enabled,
requireEmailVerification: isEmailVerificationRequired(requiredValue),
...(settings.config ? maskConfig(settings.config) : { provider: null }),
}
}
@@ -113,7 +135,14 @@ export async function saveEmailConfig(
deps: Pick<EmailConfigDeps, 'systemOptions'>,
input: SaveEmailConfigInput,
): Promise<void> {
await deps.systemOptions.setMany(configEntries(input).map(([key, value]) => ({ key, value })))
if (input.requireEmailVerification && !input.enabled) {
throw badRequest('Email must be enabled before email verification can be required')
}
const existingSecret =
input.provider === 'cloudflare'
? null
: await deps.systemOptions.getValue(input.provider === 'smtp' ? 'email_smtp_pass' : 'email_http_api_key')
await deps.systemOptions.setMany(configEntries(input, existingSecret).map(([key, value]) => ({ key, value })))
}
export async function sendTestEmail(
+35 -2
View File
@@ -21,6 +21,7 @@ type EmailConfigResponse = Awaited<ReturnType<typeof getEmailConfig>>
interface FormState {
enabled: boolean
requireEmailVerification: boolean
provider: ProviderType
from: string
smtpHost: string
@@ -34,6 +35,7 @@ interface FormState {
const emptyForm: FormState = {
enabled: false,
requireEmailVerification: false,
provider: 'smtp',
from: '',
smtpHost: '',
@@ -50,6 +52,7 @@ function formToPayload(form: FormState): EmailConfigData {
return {
provider: 'smtp',
enabled: form.enabled,
requireEmailVerification: form.requireEmailVerification,
from: form.from,
smtp: {
host: form.smtpHost,
@@ -64,12 +67,14 @@ function formToPayload(form: FormState): EmailConfigData {
return {
provider: 'cloudflare',
enabled: form.enabled,
requireEmailVerification: form.requireEmailVerification,
from: form.from,
}
}
return {
provider: 'http',
enabled: form.enabled,
requireEmailVerification: form.requireEmailVerification,
from: form.from,
http: { url: form.httpUrl, apiKey: form.httpApiKey },
}
@@ -77,12 +82,19 @@ function formToPayload(form: FormState): EmailConfigData {
function formStateFromConfig(data: EmailConfigResponse | undefined): FormState {
if (!data) return emptyForm
if (data.provider === null) return { ...emptyForm, enabled: data.enabled }
if (data.provider === null) {
return {
...emptyForm,
enabled: data.enabled,
requireEmailVerification: data.requireEmailVerification,
}
}
const config = data as EmailConfigData
if (config.provider === 'smtp') {
return {
enabled: config.enabled,
requireEmailVerification: config.requireEmailVerification,
provider: 'smtp',
from: config.from,
smtpHost: config.smtp.host,
@@ -98,6 +110,7 @@ function formStateFromConfig(data: EmailConfigResponse | undefined): FormState {
if (config.provider === 'http') {
return {
enabled: config.enabled,
requireEmailVerification: config.requireEmailVerification,
provider: 'http',
from: config.from,
smtpHost: '',
@@ -112,6 +125,7 @@ function formStateFromConfig(data: EmailConfigResponse | undefined): FormState {
return {
enabled: config.enabled,
requireEmailVerification: config.requireEmailVerification,
provider: 'cloudflare',
from: config.from,
smtpHost: '',
@@ -202,6 +216,9 @@ export function EmailConfigSection() {
<Badge variant={savedForm.enabled ? 'default' : 'secondary'}>
{savedForm.enabled ? t('admin.auth.enabled') : t('common.disabled')}
</Badge>
{savedForm.requireEmailVerification && (
<Badge variant="secondary">{t('admin.auth.emailVerificationRequired')}</Badge>
)}
<Button variant="outline" size="sm" onClick={() => setTestDialogOpen(true)} disabled={!savedForm.enabled}>
{t('admin.auth.testEmail')}
</Button>
@@ -242,7 +259,23 @@ export function EmailConfigSection() {
<AdminFormLabel htmlFor="emailEnabled" help={t('admin.auth.emailEnabledHint')}>
{t('admin.auth.emailEnabled')}
</AdminFormLabel>
<Switch id="emailEnabled" checked={form.enabled} onCheckedChange={(v) => update({ enabled: !!v })} />
<Switch
id="emailEnabled"
checked={form.enabled}
onCheckedChange={(enabled) => update({ enabled, ...(!enabled ? { requireEmailVerification: false } : {}) })}
/>
</div>
<div className="flex items-center justify-between gap-4">
<AdminFormLabel htmlFor="requireEmailVerification" help={t('admin.auth.emailVerificationHint')}>
{t('admin.auth.emailVerificationRequired')}
</AdminFormLabel>
<Switch
id="requireEmailVerification"
checked={form.requireEmailVerification}
disabled={!form.enabled}
onCheckedChange={(requireEmailVerification) => update({ requireEmailVerification })}
/>
</div>
<AdminFormField id="email-provider" label={t('admin.auth.emailProvider')} required>
+2
View File
@@ -75,6 +75,8 @@ const ADMIN_AUTH_KEYS = [
'admin.auth.emailSection',
'admin.auth.emailEnabled',
'admin.auth.emailEnabledHint',
'admin.auth.emailVerificationRequired',
'admin.auth.emailVerificationHint',
'admin.auth.emailProvider',
'admin.auth.emailProviderPlaceholder',
'admin.auth.emailCloudflare',
+5
View File
@@ -46,6 +46,9 @@
"auth.resetSuccess": "Password reset. You can now sign in.",
"auth.resetFailed": "Could not reset password. The link may have expired.",
"auth.resetTokenMissing": "This reset link is invalid or has expired.",
"auth.verifyEmailTitle": "Check your email",
"auth.verifyEmailSent": "We sent a verification link to {{email}}. Open it to finish creating your account.",
"auth.emailNotVerified": "Your email is not verified. We sent a new verification link.",
"nav.main": "Main",
"nav.files": "My Files",
"nav.shares": "Shares",
@@ -923,6 +926,8 @@
"admin.auth.emailSection": "Email Configuration",
"admin.auth.emailEnabled": "Enable Email",
"admin.auth.emailEnabledHint": "Controls whether any email provider is allowed to send messages.",
"admin.auth.emailVerificationRequired": "Require email verification",
"admin.auth.emailVerificationHint": "Email and password users must open the verification link before they can sign in.",
"admin.auth.emailProvider": "Provider Type",
"admin.auth.emailProviderPlaceholder": "Select email provider",
"admin.auth.emailCloudflare": "Cloudflare Email",
+5
View File
@@ -46,6 +46,9 @@
"auth.resetSuccess": "密码已重置,现在可以登录了。",
"auth.resetFailed": "无法重置密码,链接可能已过期。",
"auth.resetTokenMissing": "该重置链接无效或已过期。",
"auth.verifyEmailTitle": "检查你的邮箱",
"auth.verifyEmailSent": "验证邮件已发送至 {{email}}。请点击邮件中的链接完成注册。",
"auth.emailNotVerified": "邮箱尚未验证,我们已重新发送验证邮件。",
"nav.main": "主要",
"nav.files": "我的文件",
"nav.shares": "我的分享",
@@ -923,6 +926,8 @@
"admin.auth.emailSection": "邮件配置",
"admin.auth.emailEnabled": "启用邮件",
"admin.auth.emailEnabledHint": "控制是否允许任何邮件提供商发送邮件。",
"admin.auth.emailVerificationRequired": "邮箱注册必须验证",
"admin.auth.emailVerificationHint": "开启后,邮箱密码用户必须点击验证邮件中的链接才能登录。",
"admin.auth.emailProvider": "提供商类型",
"admin.auth.emailProviderPlaceholder": "选择邮件提供商",
"admin.auth.emailCloudflare": "Cloudflare 邮件",
+18 -3
View File
@@ -3787,7 +3787,12 @@ describe('api', () => {
describe('email config API', () => {
it('getEmailConfig fetches admin email config', async () => {
const payload = { enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }
const payload = {
enabled: true,
requireEmailVerification: true,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getEmailConfig()
@@ -3801,7 +3806,12 @@ describe('api', () => {
it('saveEmailConfig PUTs the expected payload', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
const payload = { enabled: true, provider: 'cloudflare' as const, from: 'no-reply@zpan.space' }
const payload = {
enabled: true,
requireEmailVerification: true,
provider: 'cloudflare' as const,
from: 'no-reply@zpan.space',
}
await saveEmailConfig(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
@@ -3814,7 +3824,12 @@ describe('api', () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'bad config' }, false, 400))
await expect(
saveEmailConfig({ enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }),
saveEmailConfig({
enabled: true,
requireEmailVerification: false,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
}),
).rejects.toThrow('bad config')
})
+4
View File
@@ -794,6 +794,7 @@ export function getSiteInvitation(token: string) {
export interface SmtpEmailConfig {
enabled: boolean
requireEmailVerification: boolean
provider: 'smtp'
from: string
smtp: { host: string; port: number; user: string; pass: string; secure: boolean }
@@ -801,6 +802,7 @@ export interface SmtpEmailConfig {
export interface HttpEmailConfig {
enabled: boolean
requireEmailVerification: boolean
provider: 'http'
from: string
http: { url: string; apiKey: string }
@@ -808,6 +810,7 @@ export interface HttpEmailConfig {
export interface CloudflareEmailConfig {
enabled: boolean
requireEmailVerification: boolean
provider: 'cloudflare'
from: string
}
@@ -816,6 +819,7 @@ export type EmailConfigData = SmtpEmailConfig | HttpEmailConfig | CloudflareEmai
export interface EmptyEmailConfigData {
enabled: boolean
requireEmailVerification: boolean
provider: null
}
+5 -1
View File
@@ -55,7 +55,11 @@ function SignIn() {
: await signIn.username({ username: identity, password, callbackURL: '/files', fetchOptions })
if (result.error) {
setError(result.error.message ?? t('auth.signInFailed'))
setError(
result.error.code === 'EMAIL_NOT_VERIFIED'
? t('auth.emailNotVerified')
: (result.error.message ?? t('auth.signInFailed')),
)
return
}
if (redirectTo) {
+21
View File
@@ -36,6 +36,7 @@ function SignUp() {
const [password, setPassword] = useState('')
const [inviteCode, setInviteCode] = useState('')
const [error, setError] = useState('')
const [verificationSentTo, setVerificationSentTo] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [formExpanded, setFormExpanded] = useState(providers.length <= 3)
const [captchaToken, setCaptchaToken] = useState('')
@@ -124,6 +125,22 @@ function SignUp() {
)
}
if (verificationSentTo) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex w-full max-w-sm flex-col gap-6 p-6 text-center">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">{t('auth.verifyEmailTitle')}</h1>
<p className="text-muted-foreground">{t('auth.verifyEmailSent', { email: verificationSentTo })}</p>
</div>
<Button asChild variant="outline">
<Link to="/sign-in">{t('auth.backToSignIn')}</Link>
</Button>
</div>
</div>
)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
@@ -143,6 +160,10 @@ function SignUp() {
setError(result.error.message ?? t('auth.signUpFailed'))
return
}
if (result.data?.token === null) {
setVerificationSentTo(email)
return
}
navigate({ to: '/files' })
} finally {
setLoading(false)