From d75d7e5461d0a9f10d3fd23b067d65dff84e7677 Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 20:22:02 -0400 Subject: [PATCH 1/7] feat(email): add cloudflare worker mail service toggle --- server/auth.integration.test.ts | 3 + server/auth.ts | 28 ++-- server/bootstrap.ts | 2 +- .../routes/email-config.integration.test.ts | 151 +++++++++++++++++- server/routes/email-config.ts | 36 +++-- server/routes/shares.ts | 2 +- server/services/email.integration.test.ts | 104 +++++++++++- server/services/email.ts | 134 ++++++++++++++-- .../share-notification.integration.test.ts | 5 + server/services/share-notification.ts | 25 +-- server/test/setup.ts | 11 +- src/components/admin/email-config-section.tsx | 52 +++++- src/i18n/admin-auth-locale.test.ts | 12 ++ src/i18n/locales/en.json | 4 + src/i18n/locales/zh.json | 4 + src/lib/api.test.ts | 54 +++++++ src/lib/api.ts | 17 +- workers/bootstrap.ts | 2 +- wrangler.toml | 4 + 19 files changed, 579 insertions(+), 71 deletions(-) diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index 88eb26d6..b3887bc6 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -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' }, diff --git a/server/auth.ts b/server/auth.ts index 2af8c499..b50223e2 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -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 { - 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 = { not_found: 'Invalid invite code', already_used: 'Invite code already used', @@ -122,7 +114,13 @@ function buildVerificationEmailHtml(url: string): string { ` } -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), diff --git a/server/bootstrap.ts b/server/bootstrap.ts index ea6fd677..a06fbe8d 100644 --- a/server/bootstrap.ts +++ b/server/bootstrap.ts @@ -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) } diff --git a/server/routes/email-config.integration.test.ts b/server/routes/email-config.integration.test.ts index 51f6a3d5..93bbd714 100644 --- a/server/routes/email-config.integration.test.ts +++ b/server/routes/email-config.integration.test.ts @@ -4,6 +4,7 @@ import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' async function seedSmtpConfig(db: Awaited>['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>['db' async function seedHttpConfig(db: Awaited>['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>['db' ]) } +async function seedCloudflareConfig(db: Awaited>['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,13 @@ 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 - expect(body).toEqual({ provider: null }) + expect(body).toEqual({ enabled: false, provider: null }) }) it('returns masked SMTP config after SMTP config is saved', async () => { @@ -83,6 +93,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 + expect(body.enabled).toBe(true) expect(body.provider).toBe('smtp') expect(body.from).toBe('no-reply@example.com') const smtp = body.smtp as Record @@ -104,6 +115,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 + expect(body.enabled).toBe(true) expect(body.provider).toBe('http') expect(body.from).toBe('no-reply@example.com') const http = body.http as Record @@ -113,6 +125,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 +155,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 +180,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 +210,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 +232,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 +256,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 +268,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 +282,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 +293,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 +306,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 + expect(body.enabled).toBe(false) + expect(body.provider).toBe('smtp') + }) }) describe('Admin Email Config API — POST /test', () => { @@ -330,7 +426,34 @@ describe('Admin Email Config API — POST /test', () => { expect(res.status).toBe(400) const body = (await res.json()) as Record 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 + expect(body.success).toBe(false) + expect(String(body.error)).toContain('Email is disabled') }) it('returns 400 for invalid to email', async () => { @@ -345,4 +468,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() + }) }) diff --git a/server/routes/email-config.ts b/server/routes/email-config.ts index b0582ca0..d2fba44e 100644 --- a/server/routes/email-config.ts +++ b/server/routes/email-config.ts @@ -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 { }, } } + if (config.provider === 'cloudflare') { + return { + provider: config.provider, + from: config.from, + } + } return { provider: config.provider, from: config.from, @@ -76,13 +90,16 @@ async function saveOptions(db: Database, entries: [string, string][]) { const app = new Hono() .use(requireAdmin) .get('/', async (c) => { - const db = c.get('platform').db + const platform = c.get('platform') try { - const config = await getEmailConfig(db) - return c.json(maskConfig(config)) + const settings = await getEmailSettings(platform) + return c.json({ + enabled: settings.enabled, + ...(settings.config ? maskConfig(settings.config) : { provider: null }), + }) } catch (e) { if (e instanceof Error && e.message.includes('not configured')) { - return c.json({ provider: null }) + return c.json({ enabled: false, provider: null }) } throw e } @@ -92,6 +109,7 @@ const app = new Hono() 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 +122,7 @@ const app = new Hono() ['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 +130,10 @@ const app = new Hono() 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: '

Test Email

Your email configuration is working correctly.

', diff --git a/server/routes/shares.ts b/server/routes/shares.ts index 19ece369..8574f420 100644 --- a/server/routes/shares.ts +++ b/server/routes/shares.ts @@ -332,7 +332,7 @@ export const authedShares = new Hono() 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), ) } diff --git a/server/services/email.integration.test.ts b/server/services/email.integration.test.ts index 0c01e089..a5fa77cf 100644 --- a/server/services/email.integration.test.ts +++ b/server/services/email.integration.test.ts @@ -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, isEmailConfigured, sendEmail } from './email.js' const sendMailMock = vi.fn() @@ -120,6 +121,42 @@ describe('getEmailConfig', () => { ]) await expect(getEmailConfig(db)).rejects.toThrow('Unknown email provider: unknown') }) + + 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: (_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: (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 +168,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 +196,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 +230,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 +242,65 @@ 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: (key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined), + } satisfies Platform + + await sendEmail(platform, { to: 'user@example.com', subject: 'Hello', html: '

Hi there

' }) + + expect(sendMock).toHaveBeenCalledWith({ + to: 'user@example.com', + from: 'no-reply@zpan.space', + subject: 'Hello', + html: '

Hi there

', + 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: (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: '

Hi

' })).rejects.toThrow( + 'Email is disabled', + ) + }) +}) diff --git a/server/services/email.ts b/server/services/email.ts index f21af08c..7e0cae09 100644 --- a/server/services/email.ts +++ b/server/services/email.ts @@ -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(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> { const rows = await db @@ -35,14 +73,25 @@ async function loadEmailOptions(db: Database): Promise> { return new Map(rows.map((r) => [r.key, r.value])) } -export async function getEmailConfig(db: Database): Promise { +function isEmailEnabledOption(opts: Map): boolean { + return opts.get('email_enabled') === 'true' +} + +export async function getEmailConfig(source: EmailSource): Promise { + 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 { } } + 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 { + 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 { + 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 { // 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 { - 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 { + 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 { + 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) } diff --git a/server/services/share-notification.integration.test.ts b/server/services/share-notification.integration.test.ts index a3c9c1da..ac14762d 100644 --- a/server/services/share-notification.integration.test.ts +++ b/server/services/share-notification.integration.test.ts @@ -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', diff --git a/server/services/share-notification.ts b/server/services/share-notification.ts index 09c1055b..cda61ace 100644 --- a/server/services/share-notification.ts +++ b/server/services/share-notification.ts @@ -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 { - 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 { const expiryLine = opts.expiresAt ? `

This share expires on ${opts.expiresAt.toISOString().split('T')[0]}.

` : '' - 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 { + 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) } diff --git a/server/test/setup.ts b/server/test/setup.ts index 88e4ce9b..f5504675 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -292,7 +292,10 @@ const APP_SCHEMA_SQL = ` ); ` -export async function createTestApp(envOverrides: Record = {}) { +export async function createTestApp( + envOverrides: Record = {}, + bindingOverrides: Record = {}, +) { 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 = {}) { const platform: Platform = { db, getEnv: (key: string) => envOverrides[key], - getBinding: () => undefined, + getBinding: (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) { diff --git a/src/components/admin/email-config-section.tsx b/src/components/admin/email-config-section.tsx index 13cc5fc9..4ead7dbe 100644 --- a/src/components/admin/email-config-section.tsx +++ b/src/components/admin/email-config-section.tsx @@ -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() {

{t('admin.auth.emailSection')}

+
+
+ +

{t('admin.auth.emailEnabledHint')}

+
+ update({ enabled: !!v })} /> +
+
update({ httpApiKey: e.target.value })} />
+ ) : ( +

{t('admin.auth.emailCloudflareHint')}

)}
-
diff --git a/src/i18n/admin-auth-locale.test.ts b/src/i18n/admin-auth-locale.test.ts index e6d63fe9..06bc15e6 100644 --- a/src/i18n/admin-auth-locale.test.ts +++ b/src/i18n/admin-auth-locale.test.ts @@ -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') }) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9cee8983..f1075d46 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0aa642e8..6b64fee2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "发件地址", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 7782eb07..bfc5b365 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -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') + }) + }) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 575d68de..3d59868b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(emailConfig.index.$get()) + return unwrap(emailConfig.index.$get()) } export function saveEmailConfig(data: EmailConfigData) { diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts index 9c9ea75c..d9f8a7cb 100644 --- a/workers/bootstrap.ts +++ b/workers/bootstrap.ts @@ -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) diff --git a/wrangler.toml b/wrangler.toml index 4c77d7b4..810a05e9 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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 From 2272a87616a1aefa202357d97612ab55cb3603b4 Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 20:38:59 -0400 Subject: [PATCH 2/7] test(email): enable site invitation mail fixtures --- server/routes/site-invitations.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/routes/site-invitations.integration.test.ts b/server/routes/site-invitations.integration.test.ts index 5f228a58..46ca8df2 100644 --- a/server/routes/site-invitations.integration.test.ts +++ b/server/routes/site-invitations.integration.test.ts @@ -48,6 +48,7 @@ describe('Admin Site Invitations API', () => { async function seedEmailOptions(ctx: Awaited>) { 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' }, From 265772444849795828a1f7e9d515a4d021b87cfb Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 21:21:16 -0400 Subject: [PATCH 3/7] fix(e2e): stabilize admin and site invitation flows --- e2e/helpers.ts | 1 + e2e/site-invitations.spec.ts | 65 +++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 6693c0dd..9c0c8337 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -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 diff --git a/e2e/site-invitations.spec.ts b/e2e/site-invitations.spec.ts index 364f0e36..70559be0 100644 --- a/e2e/site-invitations.spec.ts +++ b/e2e/site-invitations.spec.ts @@ -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(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([ From 115aa4a33dee3ac10f0af8f151b797003ad8db7e Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 21:34:16 -0400 Subject: [PATCH 4/7] fix(test): correct teams upgrade hint scenario --- src/routes/_authenticated/teams/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/_authenticated/teams/index.test.tsx b/src/routes/_authenticated/teams/index.test.tsx index 39b4093d..5795c3f3 100644 --- a/src/routes/_authenticated/teams/index.test.tsx +++ b/src/routes/_authenticated/teams/index.test.tsx @@ -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') From 7b9206f7e91df896106c6eae2606b2e914eb069c Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 21:41:09 -0400 Subject: [PATCH 5/7] fix(test): stabilize cloudflare storages coverage --- server/routes/storages.cf-test.ts | 76 +++++++++++++++++-------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/server/routes/storages.cf-test.ts b/server/routes/storages.cf-test.ts index 87468dc3..a971d50c 100644 --- a/server/routes/storages.cf-test.ts +++ b/server/routes/storages.cf-test.ts @@ -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 + 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 - 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 + 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 - 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', From 42de0f831540b07fae4e7065ef89775901df2353 Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 21:49:26 -0400 Subject: [PATCH 6/7] test(email): cover cloudflare fallback branches --- .../routes/email-config.integration.test.ts | 13 ++++ server/services/email.integration.test.ts | 60 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/server/routes/email-config.integration.test.ts b/server/routes/email-config.integration.test.ts index 93bbd714..74fab951 100644 --- a/server/routes/email-config.integration.test.ts +++ b/server/routes/email-config.integration.test.ts @@ -85,6 +85,19 @@ describe('Admin Email Config API — GET', () => { 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 () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) diff --git a/server/services/email.integration.test.ts b/server/services/email.integration.test.ts index a5fa77cf..7dc534ea 100644 --- a/server/services/email.integration.test.ts +++ b/server/services/email.integration.test.ts @@ -2,7 +2,7 @@ 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, isEmailConfigured, sendEmail } from './email.js' +import { getEmailConfig, getEmailSettings, isEmailConfigured, sendEmail } from './email.js' const sendMailMock = vi.fn() @@ -122,6 +122,21 @@ 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: (_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([ @@ -303,4 +318,47 @@ describe('sendEmail — Cloudflare provider', () => { 'Email is disabled', ) }) + + 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('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: (key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined), + } satisfies Platform + + await sendEmail(platform, { + to: 'user@example.com', + subject: 'Hello', + html: '

Hi there

', + text: 'Plain text body', + }) + + expect(sendMock).toHaveBeenCalledWith({ + to: 'user@example.com', + from: 'no-reply@zpan.space', + subject: 'Hello', + html: '

Hi there

', + text: 'Plain text body', + }) + }) }) From 5164c89a6f9f54cc5aba773587c5d8376aff3b7e Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 21:57:56 -0400 Subject: [PATCH 7/7] test(email): close patch coverage gaps --- server/routes/email-config.ts | 17 ++++-------- server/services/email.integration.test.ts | 32 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/server/routes/email-config.ts b/server/routes/email-config.ts index d2fba44e..3f47b046 100644 --- a/server/routes/email-config.ts +++ b/server/routes/email-config.ts @@ -91,18 +91,11 @@ const app = new Hono() .use(requireAdmin) .get('/', async (c) => { const platform = c.get('platform') - try { - const settings = await getEmailSettings(platform) - return c.json({ - enabled: settings.enabled, - ...(settings.config ? maskConfig(settings.config) : { provider: null }), - }) - } catch (e) { - if (e instanceof Error && e.message.includes('not configured')) { - return c.json({ enabled: false, provider: null }) - } - throw e - } + 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 diff --git a/server/services/email.integration.test.ts b/server/services/email.integration.test.ts index 7dc534ea..027f9dc6 100644 --- a/server/services/email.integration.test.ts +++ b/server/services/email.integration.test.ts @@ -319,6 +319,16 @@ describe('sendEmail — Cloudflare provider', () => { ) }) + 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([ @@ -332,6 +342,28 @@ describe('sendEmail — Cloudflare provider', () => { }) }) + 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' })