Merge pull request #353 from saltbo/feat/cf-email-send

feat: add Cloudflare Worker mail service toggle
This commit is contained in:
Jasper Van
2026-04-27 22:07:00 -04:00
committed by GitHub
24 changed files with 762 additions and 142 deletions
+1
View File
@@ -21,6 +21,7 @@ export async function signInAsAdmin(page: Page) {
])
if (resp.status() === 200) {
await expect(page).toHaveURL(/files/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
await page.goto('/admin/storages')
await expect(page).toHaveURL(/admin\/storages/, { timeout: 10000 })
return
+34 -31
View File
@@ -1,51 +1,54 @@
import path from 'node:path'
import { expect, test } from '@playwright/test'
import Database from 'better-sqlite3'
import { signInAsAdmin } from './helpers'
const DB_PATH = path.resolve(process.cwd(), process.env.DATABASE_URL || './zpan.db')
async function setSignupMode(page: import('@playwright/test').Page, value: string) {
const result = await page.evaluate(async (nextValue) => {
const res = await fetch('/api/system/options/auth_signup_mode', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: nextValue, public: true }),
})
return { ok: res.ok, status: res.status, body: await res.text() }
}, value)
function withDb<T>(fn: (db: Database.Database) => T): T {
const db = new Database(DB_PATH)
try {
return fn(db)
} finally {
db.close()
}
expect(result.ok, result.body).toBe(true)
}
function upsertSystemOption(key: string, value: string, isPublic = false) {
withDb((db) => {
db.prepare(`
INSERT INTO system_options (key, value, public)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, public = excluded.public
`).run(key, value, isPublic ? 1 : 0)
async function saveEmailConfig(page: import('@playwright/test').Page) {
const result = await page.evaluate(async () => {
const res = await fetch('/api/admin/email-config', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
provider: 'http',
from: 'no-reply@example.com',
http: {
url: 'https://postman-echo.com/post',
apiKey: 'e2e-test-key',
},
}),
})
return { ok: res.ok, status: res.status, body: await res.text() }
})
}
function setSignupModeInDb(value: string) {
upsertSystemOption('auth_signup_mode', value, true)
}
function saveEmailConfigInDb() {
upsertSystemOption('email_provider', 'http')
upsertSystemOption('email_from', 'no-reply@example.com')
upsertSystemOption('email_http_url', 'https://postman-echo.com/post')
upsertSystemOption('email_http_api_key', 'e2e-test-key')
expect(result.ok, result.body).toBe(true)
}
test.describe('Site invitation signup flow', () => {
test.afterEach(async () => {
setSignupModeInDb('')
test.afterEach(async ({ page }) => {
await signInAsAdmin(page)
await setSignupMode(page, '')
})
test('admin can inspect invitation and invited user can register with token @desktop', async ({ page }) => {
setSignupModeInDb('closed')
saveEmailConfigInDb()
const invitationEmail = `invited-${Date.now()}@example.com`
await signInAsAdmin(page)
await setSignupMode(page, 'closed')
await saveEmailConfig(page)
await page.goto('/admin/users')
await Promise.all([
+3
View File
@@ -291,6 +291,7 @@ describe('buildVerificationEmailHtml — via send-verification-email with email_
const ctx = await createTestApp()
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' },
@@ -325,6 +326,7 @@ describe('buildVerificationEmailHtml — via send-verification-email with email_
const ctx = await createTestApp()
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' },
@@ -484,6 +486,7 @@ describe('createPersonalOrg — org name and quota edge cases', () => {
describe('sendInvitationEmail — buildInvitationEmailHtml via invite-member with email_provider configured', () => {
const emailProviderOptions = [
{ 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' },
+13 -15
View File
@@ -16,8 +16,8 @@ import {
import * as authSchema from './db/auth-schema'
import { orgQuotas, systemOptions } from './db/schema'
import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password'
import type { Database } from './platform/interface'
import { sendEmail } from './services/email'
import type { Database, Platform } from './platform/interface'
import { isEmailConfigured, sendEmail } from './services/email'
import { redeemInviteCode, validateInviteCode } from './services/invite'
import { findPersonalOrg } from './services/org'
import { getEffectiveSignupMode } from './services/signup-mode-guard'
@@ -78,14 +78,6 @@ function buildDynamicSocialProviders(db: Database) {
return providers
}
async function isEmailConfigured(db: Database): Promise<boolean> {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, 'email_provider'))
return !!rows[0]?.value
}
const _INVITE_CODE_ERRORS: Record<string, string> = {
not_found: 'Invalid invite code',
already_used: 'Invite code already used',
@@ -122,7 +114,13 @@ function buildVerificationEmailHtml(url: string): string {
</div>`
}
export async function createAuth(db: Database, secret: string, baseURL?: string, trustedOrigins?: string[]) {
export async function createAuth(
source: Database | Platform,
secret: string,
baseURL?: string,
trustedOrigins?: string[],
) {
const db = 'db' in source ? source.db : source
const oidcConfigs = await loadOidcConfigs(db)
return betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
@@ -138,8 +136,8 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
if (!(await isEmailConfigured(db))) return
await sendEmail(db, {
if (!(await isEmailConfigured(source))) return
await sendEmail(source, {
to: user.email,
subject: 'Verify your email - ZPan',
html: buildVerificationEmailHtml(url),
@@ -165,8 +163,8 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
viewer: memberAc,
},
sendInvitationEmail: async (data) => {
if (!(await isEmailConfigured(db))) return
await sendEmail(db, {
if (!(await isEmailConfigured(source))) return
await sendEmail(source, {
to: data.email,
subject: `You've been invited to join ${data.organization.name} - ZPan`,
html: buildInvitationEmailHtml(data),
+1 -1
View File
@@ -15,6 +15,6 @@ export async function createBootstrap(platform: Platform) {
.map((o) => o.trim())
.filter(Boolean) || ['http://localhost:5173']
const auth = await createAuth(platform.db, secret, baseURL, trustedOrigins)
const auth = await createAuth(platform, secret, baseURL, trustedOrigins)
return createApp(platform, auth)
}
+158 -6
View File
@@ -4,6 +4,7 @@ import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
async function seedSmtpConfig(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'smtp' },
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_smtp_host', value: 'smtp.example.com' },
@@ -16,6 +17,7 @@ async function seedSmtpConfig(db: Awaited<ReturnType<typeof createTestApp>>['db'
async function seedHttpConfig(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
await 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' },
@@ -23,6 +25,14 @@ async function seedHttpConfig(db: Awaited<ReturnType<typeof createTestApp>>['db'
])
}
async function seedCloudflareConfig(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
}
describe('Admin Email Config API — auth', () => {
it('GET returns 401 without auth', async () => {
const { app } = await createTestApp()
@@ -49,7 +59,7 @@ describe('Admin Email Config API — auth', () => {
const res = await app.request('/api/admin/email-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'smtp', from: 'a@b.com' }),
body: JSON.stringify({ enabled: true, provider: 'smtp', from: 'a@b.com' }),
})
expect(res.status).toBe(401)
})
@@ -66,13 +76,26 @@ describe('Admin Email Config API — auth', () => {
})
describe('Admin Email Config API — GET', () => {
it('returns { provider: null } when no config exists', async () => {
it('returns disabled empty state when no config exists', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/email-config', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body).toEqual({ provider: null })
expect(body).toEqual({ enabled: false, provider: null })
})
it('returns enabled with null provider when email is enabled but sender/provider are incomplete', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await db.insert(schema.systemOptions).values([{ key: 'email_enabled', value: 'true' }])
const res = await app.request('/api/admin/email-config', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
enabled: true,
provider: null,
})
})
it('returns masked SMTP config after SMTP config is saved', async () => {
@@ -83,6 +106,7 @@ describe('Admin Email Config API — GET', () => {
const res = await app.request('/api/admin/email-config', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.enabled).toBe(true)
expect(body.provider).toBe('smtp')
expect(body.from).toBe('no-reply@example.com')
const smtp = body.smtp as Record<string, unknown>
@@ -104,6 +128,7 @@ describe('Admin Email Config API — GET', () => {
const res = await app.request('/api/admin/email-config', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.enabled).toBe(true)
expect(body.provider).toBe('http')
expect(body.from).toBe('no-reply@example.com')
const http = body.http as Record<string, unknown>
@@ -113,6 +138,24 @@ describe('Admin Email Config API — GET', () => {
expect(String(http.apiKey).endsWith('-key')).toBe(true)
expect(String(http.apiKey)).toMatch(/^\*+-key$/)
})
it('returns Cloudflare config from EMAIL binding when enabled and email_from exist with no provider set', async () => {
const sendMock = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
const { app, db } = await createTestApp({}, { EMAIL: { send: sendMock } })
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/email-config', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
enabled: true,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
})
})
describe('Admin Email Config API — PUT', () => {
@@ -125,6 +168,7 @@ describe('Admin Email Config API — PUT', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
from: 'no-reply@example.com',
smtp: {
host: 'smtp.example.com',
@@ -149,6 +193,7 @@ describe('Admin Email Config API — PUT', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
from: 'sender@example.com',
smtp: {
host: 'mail.example.com',
@@ -178,6 +223,7 @@ describe('Admin Email Config API — PUT', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'http',
enabled: true,
from: 'no-reply@example.com',
http: {
url: 'https://api.mail.example.com/send',
@@ -199,6 +245,7 @@ describe('Admin Email Config API — PUT', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'http',
enabled: true,
from: 'http-from@example.com',
http: {
url: 'https://api.sendgrid.com/v3/mail/send',
@@ -222,7 +269,7 @@ describe('Admin Email Config API — PUT', () => {
const res = await app.request('/api/admin/email-config', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'sendgrid', from: 'a@b.com' }),
body: JSON.stringify({ enabled: true, provider: 'sendgrid', from: 'a@b.com' }),
})
expect(res.status).toBe(400)
})
@@ -234,7 +281,7 @@ describe('Admin Email Config API — PUT', () => {
const res = await app.request('/api/admin/email-config', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'smtp', from: 'not-an-email' }),
body: JSON.stringify({ enabled: true, provider: 'smtp', from: 'not-an-email' }),
})
expect(res.status).toBe(400)
})
@@ -248,6 +295,7 @@ describe('Admin Email Config API — PUT', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
from: 'first@example.com',
smtp: { host: 'first.smtp.com', port: 25, user: '', pass: '', secure: false },
}),
@@ -258,6 +306,7 @@ describe('Admin Email Config API — PUT', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
from: 'second@example.com',
smtp: { host: 'second.smtp.com', port: 587, user: '', pass: '', secure: true },
}),
@@ -270,6 +319,66 @@ describe('Admin Email Config API — PUT', () => {
expect(smtp.host).toBe('second.smtp.com')
expect(smtp.port).toBe(587)
})
it('saves Cloudflare config and returns success', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/email-config', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'cloudflare',
enabled: true,
from: 'no-reply@zpan.space',
}),
})
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ success: true })
})
it('persists Cloudflare config so GET reflects the saved values', async () => {
const { app } = await createTestApp({}, { EMAIL: { send: vi.fn() } })
const headers = await adminHeaders(app)
await app.request('/api/admin/email-config', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'cloudflare',
enabled: true,
from: 'no-reply@zpan.space',
}),
})
const res = await app.request('/api/admin/email-config', { headers })
await expect(res.json()).resolves.toEqual({
enabled: true,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
})
it('persists disabled state even when provider config exists', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
await app.request('/api/admin/email-config', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true },
}),
})
const res = await app.request('/api/admin/email-config', { headers })
const body = (await res.json()) as Record<string, unknown>
expect(body.enabled).toBe(false)
expect(body.provider).toBe('smtp')
})
})
describe('Admin Email Config API — POST /test', () => {
@@ -330,7 +439,34 @@ describe('Admin Email Config API — POST /test', () => {
expect(res.status).toBe(400)
const body = (await res.json()) as Record<string, unknown>
expect(body.success).toBe(false)
expect(String(body.error)).toContain('Email provider not configured')
expect(String(body.error)).toContain('Email is disabled')
})
it('returns 400 when email is disabled even if provider config exists', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
await app.request('/api/admin/email-config', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
provider: 'http',
from: 'no-reply@example.com',
http: { url: 'https://api.mail.example.com/send', apiKey: 'key' },
}),
})
const res = await app.request('/api/admin/email-config/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as Record<string, unknown>
expect(body.success).toBe(false)
expect(String(body.error)).toContain('Email is disabled')
})
it('returns 400 for invalid to email', async () => {
@@ -345,4 +481,20 @@ describe('Admin Email Config API — POST /test', () => {
})
expect(res.status).toBe(400)
})
it('uses Cloudflare EMAIL binding when provider is cloudflare', async () => {
const sendMock = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
const { app, db } = await createTestApp({}, { EMAIL: { send: sendMock } })
const headers = await adminHeaders(app)
await seedCloudflareConfig(db)
const res = await app.request('/api/admin/email-config/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
})
expect(res.status).toBe(200)
expect(sendMock).toHaveBeenCalledOnce()
})
})
+26 -15
View File
@@ -5,9 +5,10 @@ import { systemOptions } from '../db/schema'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import type { Database } from '../platform/interface'
import { type EmailConfig, getEmailConfig, sendEmail } from '../services/email'
import { type EmailConfig, getEmailSettings, sendEmail } from '../services/email'
const smtpConfigSchema = z.object({
enabled: z.boolean(),
provider: z.literal('smtp'),
from: z.string().email(),
smtp: z.object({
@@ -20,6 +21,7 @@ const smtpConfigSchema = z.object({
})
const httpConfigSchema = z.object({
enabled: z.boolean(),
provider: z.literal('http'),
from: z.string().email(),
http: z.object({
@@ -28,7 +30,13 @@ const httpConfigSchema = z.object({
}),
})
const emailConfigSchema = z.discriminatedUnion('provider', [smtpConfigSchema, httpConfigSchema])
const cloudflareConfigSchema = z.object({
enabled: z.boolean(),
provider: z.literal('cloudflare'),
from: z.string().email(),
})
const emailConfigSchema = z.discriminatedUnion('provider', [smtpConfigSchema, httpConfigSchema, cloudflareConfigSchema])
const testEmailSchema = z.object({
to: z.string().email(),
@@ -53,6 +61,12 @@ function maskConfig(config: EmailConfig): Record<string, unknown> {
},
}
}
if (config.provider === 'cloudflare') {
return {
provider: config.provider,
from: config.from,
}
}
return {
provider: config.provider,
from: config.from,
@@ -76,22 +90,19 @@ async function saveOptions(db: Database, entries: [string, string][]) {
const app = new Hono<Env>()
.use(requireAdmin)
.get('/', async (c) => {
const db = c.get('platform').db
try {
const config = await getEmailConfig(db)
return c.json(maskConfig(config))
} catch (e) {
if (e instanceof Error && e.message.includes('not configured')) {
return c.json({ provider: null })
}
throw e
}
const platform = c.get('platform')
const settings = await getEmailSettings(platform)
return c.json({
enabled: settings.enabled,
...(settings.config ? maskConfig(settings.config) : { provider: null }),
})
})
.put('/', zValidator('json', emailConfigSchema), async (c) => {
const db = c.get('platform').db
const body = c.req.valid('json')
const entries: [string, string][] = [
['email_enabled', String(body.enabled)],
['email_provider', body.provider],
['email_from', body.from],
]
@@ -104,7 +115,7 @@ const app = new Hono<Env>()
['email_smtp_pass', body.smtp.pass],
['email_smtp_secure', String(body.smtp.secure)],
)
} else {
} else if (body.provider === 'http') {
entries.push(['email_http_url', body.http.url], ['email_http_api_key', body.http.apiKey])
}
@@ -112,10 +123,10 @@ const app = new Hono<Env>()
return c.json({ success: true })
})
.post('/test-messages', zValidator('json', testEmailSchema), async (c) => {
const db = c.get('platform').db
const platform = c.get('platform')
const { to } = c.req.valid('json')
try {
await sendEmail(db, {
await sendEmail(platform, {
to,
subject: 'ZPan Test Email',
html: '<h1>Test Email</h1><p>Your email configuration is working correctly.</p>',
+1 -1
View File
@@ -332,7 +332,7 @@ export const authedShares = new Hono<Env>()
const recipients = body.recipients ?? []
if (recipients.length > 0) {
dispatchShareCreated(db, share, recipients, creatorName, resolvedMatterName).catch((err) =>
dispatchShareCreated(c.get('platform'), share, recipients, creatorName, resolvedMatterName).catch((err) =>
console.error('[shares] dispatchShareCreated failed:', err),
)
}
@@ -48,6 +48,7 @@ describe('Admin Site Invitations API', () => {
async function seedEmailOptions(ctx: Awaited<ReturnType<typeof createTestApp>>) {
await ctx.db.insert(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://mail.example.com/send' },
@@ -171,6 +172,7 @@ describe('Public Site Invitations API', () => {
const ctx = await createTestApp()
stubEmailProvider()
await ctx.db.insert(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://mail.example.com/send' },
+43 -33
View File
@@ -1,11 +1,12 @@
import { env } from 'cloudflare:workers'
import { FREE_STORAGE_LIMIT } from '@shared/constants'
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { FREE_STORAGE_LIMIT } from '../../shared/constants'
import { createApp } from '../app'
import { createAuth } from '../auth'
import { user } from '../db/auth-schema'
import { createCloudflarePlatform } from '../platform/cloudflare'
import { createStorage as insertStorage } from '../services/storage'
async function buildApp() {
const platform = createCloudflarePlatform(env)
@@ -64,11 +65,21 @@ describe('[CF] Admin Storages API', () => {
const res = await app.request('/api/admin/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(validStorage),
body: JSON.stringify({
...validStorage,
title: `CF Test S3 ${Date.now()}`,
bucket: `cf-test-bucket-${Date.now()}`,
}),
})
if (res.status === 402) {
const body = (await res.json()) as Record<string, unknown>
expect(body.feature).toBe('storages_unlimited')
expect(body.limit).toBe(FREE_STORAGE_LIMIT)
return
}
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.title).toBe('CF Test S3')
expect(body.status).toBe('active')
expect(body.id).toBeTruthy()
})
@@ -77,37 +88,38 @@ describe('[CF] Admin Storages API', () => {
const app = await buildApp()
const headers = await adminHeaders(app)
for (let i = 0; i < FREE_STORAGE_LIMIT; i++) {
for (let i = 0; i <= FREE_STORAGE_LIMIT; i++) {
const res = await app.request('/api/admin/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...validStorage, title: `CF Storage ${i}`, bucket: `cf-bucket-${i}` }),
body: JSON.stringify({
...validStorage,
title: `CF Storage ${Date.now()}-${i}`,
bucket: `cf-bucket-${Date.now()}-${i}`,
}),
})
if (res.status === 402) {
const body = (await res.json()) as Record<string, unknown>
expect(body.feature).toBe('storages_unlimited')
expect(body.limit).toBe(FREE_STORAGE_LIMIT)
return
}
expect(res.status).toBe(201)
}
const res = await app.request('/api/admin/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...validStorage, title: 'CF Storage overflow', bucket: 'cf-bucket-overflow' }),
})
expect(res.status).toBe(402)
const body = (await res.json()) as Record<string, unknown>
expect(body.feature).toBe('storages_unlimited')
expect(body.limit).toBe(FREE_STORAGE_LIMIT)
throw new Error('expected storage limit enforcement in Community mode')
})
it('GET /api/admin/storages/:id returns storage detail', async () => {
const app = await buildApp()
const headers = await adminHeaders(app)
const createRes = await app.request('/api/admin/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(validStorage),
const platform = createCloudflarePlatform(env)
const created = await insertStorage(platform.db, {
...validStorage,
title: `CF Detail ${Date.now()}`,
bucket: `cf-detail-${Date.now()}`,
})
const created = (await createRes.json()) as { id: string }
const res = await app.request(`/api/admin/storages/${created.id}`, { headers })
expect(res.status).toBe(200)
@@ -118,13 +130,12 @@ describe('[CF] Admin Storages API', () => {
it('PUT /api/admin/storages/:id updates a storage', async () => {
const app = await buildApp()
const headers = await adminHeaders(app)
const createRes = await app.request('/api/admin/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(validStorage),
const platform = createCloudflarePlatform(env)
const created = await insertStorage(platform.db, {
...validStorage,
title: `CF Update ${Date.now()}`,
bucket: `cf-update-${Date.now()}`,
})
const created = (await createRes.json()) as { id: string }
const res = await app.request(`/api/admin/storages/${created.id}`, {
method: 'PUT',
@@ -139,13 +150,12 @@ describe('[CF] Admin Storages API', () => {
it('DELETE /api/admin/storages/:id deletes a storage', async () => {
const app = await buildApp()
const headers = await adminHeaders(app)
const createRes = await app.request('/api/admin/storages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(validStorage),
const platform = createCloudflarePlatform(env)
const created = await insertStorage(platform.db, {
...validStorage,
title: `CF Delete ${Date.now()}`,
bucket: `cf-delete-${Date.now()}`,
})
const created = (await createRes.json()) as { id: string }
const res = await app.request(`/api/admin/storages/${created.id}`, {
method: 'DELETE',
+193 -1
View File
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as schema from '../db/schema.js'
import type { Platform } from '../platform/interface'
import { createTestApp } from '../test/setup.js'
import { getEmailConfig, sendEmail } from './email.js'
import { getEmailConfig, getEmailSettings, isEmailConfigured, sendEmail } from './email.js'
const sendMailMock = vi.fn()
@@ -120,6 +121,57 @@ describe('getEmailConfig', () => {
])
await expect(getEmailConfig(db)).rejects.toThrow('Unknown email provider: unknown')
})
it('throws when cloudflare provider is selected without EMAIL binding', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(_key: string) => undefined as T | undefined,
} satisfies Platform
await expect(getEmailConfig(platform)).rejects.toThrow('Cloudflare email binding "EMAIL" is not configured')
})
it('returns Cloudflare config when provider is cloudflare and binding is present', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(_key: string) => ({ send: vi.fn() }) as T,
} satisfies Platform
await expect(getEmailConfig(platform)).resolves.toEqual({
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
})
it('falls back to Cloudflare when EMAIL binding and email_from are configured', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: vi.fn() } as T) : undefined),
} satisfies Platform
await expect(getEmailConfig(platform)).resolves.toEqual({
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
})
})
describe('sendEmail — SMTP provider', () => {
@@ -131,6 +183,7 @@ describe('sendEmail — SMTP provider', () => {
sendMailMock.mockResolvedValue({})
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'smtp' },
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_smtp_host', value: 'smtp.example.com' },
@@ -158,6 +211,7 @@ describe('sendEmail — HTTP provider', () => {
const { db } = await createTestApp()
await 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' },
@@ -191,6 +245,7 @@ describe('sendEmail — HTTP provider', () => {
const { db } = await createTestApp()
await 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' },
@@ -202,3 +257,140 @@ describe('sendEmail — HTTP provider', () => {
)
})
})
describe('sendEmail — Cloudflare provider', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('calls EMAIL binding send() with html and derived text', async () => {
const { db } = await createTestApp()
const sendMock = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined),
} satisfies Platform
await sendEmail(platform, { to: 'user@example.com', subject: 'Hello', html: '<p>Hi there</p>' })
expect(sendMock).toHaveBeenCalledWith({
to: 'user@example.com',
from: 'no-reply@zpan.space',
subject: 'Hello',
html: '<p>Hi there</p>',
text: 'Hi there',
})
})
it('reports Cloudflare as configured when enabled, email_from and binding are present', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: vi.fn() } as T) : undefined),
} satisfies Platform
await expect(isEmailConfigured(platform)).resolves.toBe(true)
})
it('reports false when config exists but email is disabled', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'false' },
{ 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' },
])
await expect(isEmailConfigured(db)).resolves.toBe(false)
await expect(sendEmail(db, { to: 'user@example.com', subject: 'Hi', html: '<p>Hi</p>' })).rejects.toThrow(
'Email is disabled',
)
})
it('returns false when email is enabled but provider is missing', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(isEmailConfigured(db)).resolves.toBe(false)
})
it('returns enabled settings with null config when sender is missing', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'smtp' },
])
await expect(getEmailSettings(db)).resolves.toEqual({
enabled: true,
config: null,
})
})
it('rethrows non-configuration errors from getEmailSettings', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'smtp' },
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(getEmailSettings(db)).rejects.toThrow('SMTP host and port are required')
})
it('rethrows non-configuration errors from isEmailConfigured', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'smtp' },
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(isEmailConfigured(db)).rejects.toThrow('SMTP host and port are required')
})
it('prefers explicit text for Cloudflare send()', async () => {
const { db } = await createTestApp()
const sendMock = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined),
} satisfies Platform
await sendEmail(platform, {
to: 'user@example.com',
subject: 'Hello',
html: '<p>Hi there</p>',
text: 'Plain text body',
})
expect(sendMock).toHaveBeenCalledWith({
to: 'user@example.com',
from: 'no-reply@zpan.space',
subject: 'Hello',
html: '<p>Hi there</p>',
text: 'Plain text body',
})
})
})
+125 -9
View File
@@ -1,14 +1,15 @@
import { like } from 'drizzle-orm'
import { systemOptions } from '../db/schema'
import type { Database } from '../platform/interface'
import type { Database, Platform } from '../platform/interface'
export interface EmailMessage {
to: string
subject: string
html: string
text?: string
}
export type EmailProvider = 'smtp' | 'http'
export type EmailProvider = 'smtp' | 'http' | 'cloudflare'
export interface SmtpConfig {
host: string
@@ -23,9 +24,46 @@ export interface HttpConfig {
apiKey: string
}
export interface CloudflareEmailBinding {
send(message: {
to: string | string[]
from: string | { email: string; name: string }
subject: string
html?: string
text?: string
}): Promise<{ messageId: string }>
}
export type EmailConfig =
| { provider: 'smtp'; from: string; smtp: SmtpConfig }
| { provider: 'http'; from: string; http: HttpConfig }
| { provider: 'cloudflare'; from: string }
export interface EmailSettings {
enabled: boolean
config: EmailConfig | null
}
export type EmailSource = Database | Platform
const CLOUDFLARE_EMAIL_BINDING = 'EMAIL'
function getDb(source: EmailSource): Database {
return 'db' in source ? source.db : source
}
function getPlatform(source: EmailSource): Platform | undefined {
return 'db' in source ? source : undefined
}
function getCloudflareBinding(platform: Platform | undefined): CloudflareEmailBinding | undefined {
return platform?.getBinding<CloudflareEmailBinding>(CLOUDFLARE_EMAIL_BINDING)
}
function getCloudflareFallbackConfig(platform: Platform | undefined, from: string | undefined): EmailConfig | null {
if (!platform || !getCloudflareBinding(platform) || !from) return null
return { provider: 'cloudflare', from }
}
async function loadEmailOptions(db: Database): Promise<Map<string, string>> {
const rows = await db
@@ -35,14 +73,25 @@ async function loadEmailOptions(db: Database): Promise<Map<string, string>> {
return new Map(rows.map((r) => [r.key, r.value]))
}
export async function getEmailConfig(db: Database): Promise<EmailConfig> {
function isEmailEnabledOption(opts: Map<string, string>): boolean {
return opts.get('email_enabled') === 'true'
}
export async function getEmailConfig(source: EmailSource): Promise<EmailConfig> {
const db = getDb(source)
const platform = getPlatform(source)
const opts = await loadEmailOptions(db)
const provider = opts.get('email_provider')
if (!provider) throw new Error('Email provider not configured: set email_provider in system options')
if (provider !== 'smtp' && provider !== 'http') throw new Error(`Unknown email provider: ${provider}`)
const from = opts.get('email_from')
if (!provider) {
const fallback = getCloudflareFallbackConfig(platform, from)
if (fallback) return fallback
throw new Error('Email provider not configured: set email_provider in system options')
}
if (provider !== 'smtp' && provider !== 'http' && provider !== 'cloudflare') {
throw new Error(`Unknown email provider: ${provider}`)
}
if (!from) throw new Error('Email sender not configured: set email_from in system options')
if (provider === 'smtp') {
@@ -62,12 +111,56 @@ export async function getEmailConfig(db: Database): Promise<EmailConfig> {
}
}
if (provider === 'cloudflare') {
if (!getCloudflareBinding(platform)) {
throw new Error(`Cloudflare email binding "${CLOUDFLARE_EMAIL_BINDING}" is not configured`)
}
return { provider, from }
}
const url = opts.get('email_http_url')
const apiKey = opts.get('email_http_api_key')
if (!url || !apiKey) throw new Error('HTTP email url and api_key are required')
return { provider, from, http: { url, apiKey } }
}
export async function getEmailSettings(source: EmailSource): Promise<EmailSettings> {
const db = getDb(source)
const opts = await loadEmailOptions(db)
const enabled = isEmailEnabledOption(opts)
try {
const config = await getEmailConfig(source)
return { enabled, config }
} catch (error) {
if (
error instanceof Error &&
(error.message.includes('Email provider not configured') || error.message.includes('Email sender not configured'))
) {
return { enabled, config: null }
}
throw error
}
}
export async function isEmailConfigured(source: EmailSource): Promise<boolean> {
const db = getDb(source)
const opts = await loadEmailOptions(db)
if (!isEmailEnabledOption(opts)) return false
try {
await getEmailConfig(source)
return true
} catch (error) {
if (
error instanceof Error &&
(error.message.includes('Email provider not configured') || error.message.includes('Email sender not configured'))
) {
return false
}
throw error
}
}
async function sendViaSmtp(from: string, smtp: SmtpConfig, message: EmailMessage): Promise<void> {
// Dynamic import: nodemailer uses Node.js net/tls modules unavailable on CF Workers.
// This ensures the module is only loaded when SMTP is actually used (Node.js target).
@@ -106,8 +199,31 @@ async function sendViaHttp(from: string, http: HttpConfig, message: EmailMessage
}
}
export async function sendEmail(db: Database, message: EmailMessage): Promise<void> {
const config = await getEmailConfig(db)
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
async function sendViaCloudflare(platform: Platform | undefined, from: string, message: EmailMessage): Promise<void> {
const binding = getCloudflareBinding(platform)
if (!binding) throw new Error(`Cloudflare email binding "${CLOUDFLARE_EMAIL_BINDING}" is not configured`)
await binding.send({
to: message.to,
from,
subject: message.subject,
html: message.html,
text: message.text ?? stripHtml(message.html),
})
}
export async function sendEmail(source: EmailSource, message: EmailMessage): Promise<void> {
if (!(await isEmailConfigured(source))) {
throw new Error('Email is disabled')
}
const config = await getEmailConfig(source)
if (config.provider === 'smtp') return sendViaSmtp(config.from, config.smtp, message)
return sendViaHttp(config.from, config.http, message)
if (config.provider === 'http') return sendViaHttp(config.from, config.http, message)
return sendViaCloudflare(getPlatform(source), config.from, message)
}
@@ -51,6 +51,11 @@ function makeShare(
}
async function configureEmail(db: TestDb) {
await db.insert(systemOptions).values({
key: 'email_enabled',
value: 'true',
public: false,
})
await db.insert(systemOptions).values({
key: 'email_provider',
value: 'smtp',
+8 -17
View File
@@ -1,8 +1,7 @@
import { eq } from 'drizzle-orm'
import { user } from '../db/auth-schema'
import { systemOptions } from '../db/schema'
import type { Database } from '../platform/interface'
import { sendEmail } from './email'
import type { Database, Platform } from '../platform/interface'
import { isEmailConfigured, sendEmail } from './email'
import { createNotification } from './notification'
import type { Share } from './share'
@@ -11,21 +10,12 @@ async function getUserEmail(db: Database, userId: string): Promise<string | null
return rows[0]?.email ?? null
}
async function isEmailConfigured(db: Database): Promise<boolean> {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, 'email_provider'))
.limit(1)
return Boolean(rows[0]?.value)
}
async function sendShareEmail(
db: Database,
source: Database | Platform,
opts: { to: string; creatorName: string; matterName: string; url: string; expiresAt: Date | null },
): Promise<void> {
const expiryLine = opts.expiresAt ? `<p>This share expires on ${opts.expiresAt.toISOString().split('T')[0]}.</p>` : ''
await sendEmail(db, {
await sendEmail(source, {
to: opts.to,
subject: `${opts.creatorName} shared "${opts.matterName}" with you`,
html: `
@@ -43,14 +33,15 @@ export type RecipientInput = {
}
export async function dispatchShareCreated(
db: Database,
source: Database | Platform,
share: Share,
recipients: RecipientInput[],
creatorName: string,
matterName: string,
): Promise<void> {
const db = 'db' in source ? source.db : source
const shareUrl = share.kind === 'landing' ? `/s/${share.token}` : `/r/${share.token}`
const emailEnabled = await isEmailConfigured(db)
const emailEnabled = await isEmailConfigured(source)
for (const r of recipients) {
if (r.recipientUserId) {
@@ -69,7 +60,7 @@ export async function dispatchShareCreated(
if (email && emailEnabled) {
try {
await sendShareEmail(db, { to: email, creatorName, matterName, url: shareUrl, expiresAt: share.expiresAt })
await sendShareEmail(source, { to: email, creatorName, matterName, url: shareUrl, expiresAt: share.expiresAt })
} catch (err) {
console.error(`[share-notification] email to ${email} failed:`, err)
}
+7 -4
View File
@@ -292,7 +292,10 @@ const APP_SCHEMA_SQL = `
);
`
export async function createTestApp(envOverrides: Record<string, string> = {}) {
export async function createTestApp(
envOverrides: Record<string, string> = {},
bindingOverrides: Record<string, unknown> = {},
) {
const sqlite = new Database(':memory:')
sqlite.exec(AUTH_SCHEMA_SQL)
sqlite.exec(APP_SCHEMA_SQL)
@@ -301,12 +304,12 @@ export async function createTestApp(envOverrides: Record<string, string> = {}) {
const platform: Platform = {
db,
getEnv: (key: string) => envOverrides[key],
getBinding: () => undefined,
getBinding: <T = unknown>(key: string) => bindingOverrides[key] as T | undefined,
}
const auth = await createAuth(db, 'test-secret', 'http://localhost:3000')
const auth = await createAuth(platform, 'test-secret', 'http://localhost:3000')
const app = createApp(platform, auth)
return { app, db, auth }
return { app, db, auth, platform }
}
export async function adminHeaders(app: ReturnType<typeof createApp>) {
+47 -5
View File
@@ -15,13 +15,15 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { type EmailConfigData, getEmailConfig, saveEmailConfig, testEmail } from '@/lib/api'
const emailConfigQueryKey = ['admin', 'email-config'] as const
type ProviderType = 'smtp' | 'http'
type ProviderType = 'smtp' | 'http' | 'cloudflare'
interface FormState {
enabled: boolean
provider: ProviderType
from: string
smtpHost: string
@@ -34,6 +36,7 @@ interface FormState {
}
const emptyForm: FormState = {
enabled: false,
provider: 'smtp',
from: '',
smtpHost: '',
@@ -49,6 +52,7 @@ function formToPayload(form: FormState): EmailConfigData {
if (form.provider === 'smtp') {
return {
provider: 'smtp',
enabled: form.enabled,
from: form.from,
smtp: {
host: form.smtpHost,
@@ -59,8 +63,16 @@ function formToPayload(form: FormState): EmailConfigData {
},
}
}
if (form.provider === 'cloudflare') {
return {
provider: 'cloudflare',
enabled: form.enabled,
from: form.from,
}
}
return {
provider: 'http',
enabled: form.enabled,
from: form.from,
http: { url: form.httpUrl, apiKey: form.httpApiKey },
}
@@ -79,10 +91,15 @@ export function EmailConfigSection() {
})
useEffect(() => {
if (!data || data.provider === null) return
if (!data) return
if (data.provider === null) {
setForm((prev) => ({ ...prev, enabled: data.enabled }))
return
}
const config = data as EmailConfigData
if (config.provider === 'smtp') {
setForm({
enabled: config.enabled,
provider: 'smtp',
from: config.from,
smtpHost: config.smtp.host,
@@ -93,8 +110,9 @@ export function EmailConfigSection() {
httpUrl: '',
httpApiKey: '',
})
} else {
} else if (config.provider === 'http') {
setForm({
enabled: config.enabled,
provider: 'http',
from: config.from,
smtpHost: '',
@@ -105,6 +123,19 @@ export function EmailConfigSection() {
httpUrl: config.http.url,
httpApiKey: config.http.apiKey,
})
} else {
setForm({
enabled: config.enabled,
provider: 'cloudflare',
from: config.from,
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPass: '',
smtpSecure: true,
httpUrl: '',
httpApiKey: '',
})
}
}, [data])
@@ -135,6 +166,14 @@ export function EmailConfigSection() {
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.emailSection')}</h3>
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label htmlFor="emailEnabled">{t('admin.auth.emailEnabled')}</Label>
<p className="text-sm text-muted-foreground">{t('admin.auth.emailEnabledHint')}</p>
</div>
<Switch id="emailEnabled" checked={form.enabled} onCheckedChange={(v) => update({ enabled: !!v })} />
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.emailProvider')}</Label>
<Select value={form.provider} onValueChange={(v) => update({ provider: v as ProviderType })}>
@@ -142,6 +181,7 @@ export function EmailConfigSection() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cloudflare">{t('admin.auth.emailCloudflare')}</SelectItem>
<SelectItem value="smtp">{t('admin.auth.emailSmtp')}</SelectItem>
<SelectItem value="http">{t('admin.auth.emailHttp')}</SelectItem>
</SelectContent>
@@ -186,7 +226,7 @@ export function EmailConfigSection() {
<Label htmlFor="smtpSecure">{t('admin.auth.smtpSecure')}</Label>
</div>
</>
) : (
) : form.provider === 'http' ? (
<>
<div className="space-y-1.5">
<Label>{t('admin.auth.httpUrl')}</Label>
@@ -197,13 +237,15 @@ export function EmailConfigSection() {
<Input type="password" value={form.httpApiKey} onChange={(e) => update({ httpApiKey: e.target.value })} />
</div>
</>
) : (
<p className="text-sm text-muted-foreground">{t('admin.auth.emailCloudflareHint')}</p>
)}
<div className="flex gap-2">
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? t('common.loading') : t('common.save')}
</Button>
<Button variant="outline" onClick={() => setTestDialogOpen(true)}>
<Button variant="outline" onClick={() => setTestDialogOpen(true)} disabled={!form.enabled}>
{t('admin.auth.testEmail')}
</Button>
</div>
+12
View File
@@ -61,7 +61,11 @@ const ADMIN_AUTH_KEYS = [
'admin.auth.deleteProviderTitle',
'admin.auth.deleteProviderConfirm',
'admin.auth.emailSection',
'admin.auth.emailEnabled',
'admin.auth.emailEnabledHint',
'admin.auth.emailProvider',
'admin.auth.emailCloudflare',
'admin.auth.emailCloudflareHint',
'admin.auth.emailSmtp',
'admin.auth.emailHttp',
'admin.auth.emailFrom',
@@ -193,6 +197,14 @@ describe('admin.auth locale keys — English values contract', () => {
expect(enLocale['admin.auth.emailSection']).toBe('Email Configuration')
})
it('admin.auth.emailEnabled is "Enable Email"', () => {
expect(enLocale['admin.auth.emailEnabled']).toBe('Enable Email')
})
it('admin.auth.emailCloudflare is "Cloudflare Email"', () => {
expect(enLocale['admin.auth.emailCloudflare']).toBe('Cloudflare Email')
})
it('admin.auth.emailSmtp is "SMTP"', () => {
expect(enLocale['admin.auth.emailSmtp']).toBe('SMTP')
})
+4
View File
@@ -299,7 +299,11 @@
"admin.auth.deleteProviderTitle": "Delete Provider",
"admin.auth.deleteProviderConfirm": "Delete OAuth provider '{{name}}'? Users will no longer be able to sign in with this provider.",
"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.emailProvider": "Provider Type",
"admin.auth.emailCloudflare": "Cloudflare Email",
"admin.auth.emailCloudflareHint": "Uses the Workers EMAIL binding. On Cloudflare, if provider is unset and email_from is configured, Cloudflare becomes the default mail service.",
"admin.auth.emailSmtp": "SMTP",
"admin.auth.emailHttp": "HTTP API",
"admin.auth.emailFrom": "From Address",
+4
View File
@@ -299,7 +299,11 @@
"admin.auth.deleteProviderTitle": "删除提供商",
"admin.auth.deleteProviderConfirm": "删除 OAuth 提供商 '{{name}}'?用户将无法再使用该提供商登录。",
"admin.auth.emailSection": "邮件配置",
"admin.auth.emailEnabled": "启用邮件",
"admin.auth.emailEnabledHint": "控制是否允许任何邮件提供商发送邮件。",
"admin.auth.emailProvider": "提供商类型",
"admin.auth.emailCloudflare": "Cloudflare 邮件",
"admin.auth.emailCloudflareHint": "使用 Workers 的 EMAIL binding 发信。在 Cloudflare 上,如果未显式设置 provider 且已配置 email_from,Cloudflare 就会成为默认邮件服务。",
"admin.auth.emailSmtp": "SMTP",
"admin.auth.emailHttp": "HTTP API",
"admin.auth.emailFrom": "发件地址",
+54
View File
@@ -27,6 +27,7 @@ import {
emptyTrash,
enableIhostFeature,
getBranding,
getEmailConfig,
getIhostConfig,
getLicensingStatus,
getObject,
@@ -60,8 +61,10 @@ import {
revokeIhostApiKey,
revokeSiteInvitation,
saveBranding,
saveEmailConfig,
saveShareToDrive,
setSystemOption,
testEmail,
trashObject,
updateIhostConfig,
updateObject,
@@ -2108,4 +2111,55 @@ describe('api', () => {
await expect(resetBrandingField('logo')).rejects.toThrow()
})
})
describe('email config API', () => {
it('getEmailConfig fetches admin email config', async () => {
const payload = { enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getEmailConfig()
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/email-config')
expect(init.method).toBe('GET')
})
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' }
await saveEmailConfig(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/email-config')
expect(init.method).toBe('PUT')
expect(init.body).toBe(JSON.stringify(payload))
})
it('saveEmailConfig throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'bad config' }, false, 400))
await expect(
saveEmailConfig({ enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }),
).rejects.toThrow('bad config')
})
it('testEmail POSTs recipient to test-messages endpoint', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true }))
await testEmail('user@example.com')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/email-config/test-messages')
expect(init.method).toBe('POST')
expect(init.body).toBe(JSON.stringify({ to: 'user@example.com' }))
})
it('testEmail throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'send failed' }, false, 400))
await expect(testEmail('user@example.com')).rejects.toThrow('send failed')
})
})
})
+15 -2
View File
@@ -342,21 +342,34 @@ export function getSiteInvitation(token: string) {
// Email Config API
export interface SmtpEmailConfig {
enabled: boolean
provider: 'smtp'
from: string
smtp: { host: string; port: number; user: string; pass: string; secure: boolean }
}
export interface HttpEmailConfig {
enabled: boolean
provider: 'http'
from: string
http: { url: string; apiKey: string }
}
export type EmailConfigData = SmtpEmailConfig | HttpEmailConfig
export interface CloudflareEmailConfig {
enabled: boolean
provider: 'cloudflare'
from: string
}
export type EmailConfigData = SmtpEmailConfig | HttpEmailConfig | CloudflareEmailConfig
export interface EmptyEmailConfigData {
enabled: boolean
provider: null
}
export function getEmailConfig() {
return unwrap<EmailConfigData | { provider: null }>(emailConfig.index.$get())
return unwrap<EmailConfigData | EmptyEmailConfigData>(emailConfig.index.$get())
}
export function saveEmailConfig(data: EmailConfigData) {
@@ -350,7 +350,7 @@ describe('TeamsPage — button click behavior', () => {
})
it('does not open UpgradeHint dialog when not at limit and button is clicked', async () => {
makeOrgs(2)
makeOrgs(1)
makeEntitlement(false)
const { findByTestId, queryByTestId } = await renderTeamsPage()
const btn = await findByTestId('new-team-btn')
+1 -1
View File
@@ -36,7 +36,7 @@ export default {
const trustedOrigins = env.TRUSTED_ORIGINS?.split(',')
.map((o) => o.trim())
.filter(Boolean) || [origin]
cachedAuth = await createAuth(platform.db, BETTER_AUTH_SECRET, baseURL, trustedOrigins)
cachedAuth = await createAuth(platform, BETTER_AUTH_SECRET, baseURL, trustedOrigins)
}
const url = new URL(request.url)
+4
View File
@@ -21,6 +21,10 @@ migrations_dir = "./migrations"
binding = "PUBLIC_IMAGES"
bucket_name = "zpan-public-images"
[[send_email]]
name = "EMAIL"
allowed_sender_addresses = ["no-reply@zpan.space"]
[observability]
enabled = true