mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
Merge pull request #353 from saltbo/feat/cf-email-send
feat: add Cloudflare Worker mail service toggle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "发件地址",
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user