From 1054f5d817670b45441c7cbf41b276b611cbf862 Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 24 Sep 2025 13:59:26 +0000 Subject: [PATCH] Add Mailgun admin tools and contact API --- app/[locale]/bible/reader.tsx | 75 +++- app/[locale]/bookmarks/page.tsx | 4 +- app/[locale]/contact/page.tsx | 34 +- app/admin/mailgun/page.tsx | 437 +++++++++++++++++++++++ app/api/admin/mailgun/route.ts | 231 ++++++++++++ app/api/admin/mailgun/test/route.ts | 100 ++++++ app/api/bookmarks/all/route.ts | 10 +- app/api/contact/route.ts | 77 ++++ components/admin/layout/admin-layout.tsx | 4 +- lib/mailgun.ts | 228 ++++++++++++ package-lock.json | 265 ++++++++++++++ package.json | 2 + prisma/schema.prisma | 21 ++ 13 files changed, 1459 insertions(+), 29 deletions(-) create mode 100644 app/admin/mailgun/page.tsx create mode 100644 app/api/admin/mailgun/route.ts create mode 100644 app/api/admin/mailgun/test/route.ts create mode 100644 app/api/contact/route.ts create mode 100644 lib/mailgun.ts diff --git a/app/[locale]/bible/reader.tsx b/app/[locale]/bible/reader.tsx index 04ec498..f96fe90 100644 --- a/app/[locale]/bible/reader.tsx +++ b/app/[locale]/bible/reader.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useTranslations, useLocale } from 'next-intl' import { useAuth } from '@/hooks/use-auth' -import { useSearchParams } from 'next/navigation' +import { useSearchParams, useRouter } from 'next/navigation' import { Box, Typography, @@ -126,6 +126,7 @@ export default function BibleReaderNew() { const isMobile = useMediaQuery(theme.breakpoints.down('md')) const t = useTranslations('pages.bible') const locale = useLocale() + const router = useRouter() const searchParams = useSearchParams() const { user } = useAuth() @@ -261,6 +262,68 @@ export default function BibleReaderNew() { }) }, [locale, showAllVersions, selectedVersion]) + // Handle URL parameters for bookmark navigation + useEffect(() => { + const urlVersion = searchParams.get('version') + const urlBook = searchParams.get('book') + const urlChapter = searchParams.get('chapter') + const urlVerse = searchParams.get('verse') + + if (urlVersion && versions.length > 0) { + // Check if this version exists + const version = versions.find(v => v.id === urlVersion) + if (version && selectedVersion !== urlVersion) { + setSelectedVersion(urlVersion) + } + } + + if (urlBook && books.length > 0) { + const book = books.find(b => b.id === urlBook) + if (book && selectedBook !== urlBook) { + setSelectedBook(urlBook) + } + } + + if (urlChapter) { + const chapter = parseInt(urlChapter) + if (chapter && selectedChapter !== chapter) { + setSelectedChapter(chapter) + } + } + + if (urlVerse && verses.length > 0) { + const verseNum = parseInt(urlVerse) + if (verseNum) { + // Highlight the verse + setTimeout(() => { + const verseElement = verseRefs.current[verseNum] + if (verseElement) { + verseElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) + setHighlightedVerse(verseNum) + } + }, 500) + } + } + }, [searchParams, versions, books, verses, selectedVersion, selectedBook, selectedChapter]) + + // Function to update URL without causing full page reload + const updateUrl = useCallback((bookId?: string, chapter?: number, versionId?: string) => { + const params = new URLSearchParams() + + if (versionId || selectedVersion) { + params.set('version', versionId || selectedVersion) + } + if (bookId || selectedBook) { + params.set('book', bookId || selectedBook) + } + if (chapter || selectedChapter) { + params.set('chapter', String(chapter || selectedChapter)) + } + + const newUrl = `/${locale}/bible?${params.toString()}` + router.replace(newUrl, { scroll: false }) + }, [locale, selectedVersion, selectedBook, selectedChapter, router]) + // Fetch books when version changes useEffect(() => { if (selectedVersion) { @@ -431,16 +494,6 @@ export default function BibleReaderNew() { const currentBook = books.find(book => book.id === selectedBook) const maxChapters = currentBook?.chapters?.length || 1 - const updateUrl = (bookId: string, chapter: number, version?: string) => { - const url = new URL(window.location.href) - url.searchParams.set('book', bookId) - url.searchParams.set('chapter', chapter.toString()) - if (version) { - url.searchParams.set('version', version) - } - window.history.replaceState({}, '', url.toString()) - } - const scrollToVerse = (verseNum: number) => { const verseElement = verseRefs.current[verseNum] if (verseElement) { diff --git a/app/[locale]/bookmarks/page.tsx b/app/[locale]/bookmarks/page.tsx index ee13fc7..69a53a5 100644 --- a/app/[locale]/bookmarks/page.tsx +++ b/app/[locale]/bookmarks/page.tsx @@ -50,6 +50,7 @@ interface BookmarkItem { bookId: string chapterNum: number verseNum?: number + versionId: string } verse?: { id: string @@ -118,7 +119,8 @@ export default function BookmarksPage() { const handleNavigateToBookmark = (bookmark: BookmarkItem) => { const params = new URLSearchParams({ book: bookmark.navigation.bookId, - chapter: bookmark.navigation.chapterNum.toString() + chapter: bookmark.navigation.chapterNum.toString(), + version: bookmark.navigation.versionId }) if (bookmark.navigation.verseNum) { diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx index fbdb233..5f47117 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[locale]/contact/page.tsx @@ -50,20 +50,30 @@ export default function Contact() { setIsSubmitting(true) try { - // Simulate form submission - await new Promise(resolve => setTimeout(resolve, 1000)) - - // Here you would typically send the data to your API - console.log('Form submitted:', formData) - - setFormData({ - name: '', - email: '', - subject: '', - message: '' + const response = await fetch('/api/contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData) }) - setShowSuccess(true) + + const data = await response.json() + + if (response.ok && data.success) { + setFormData({ + name: '', + email: '', + subject: '', + message: '' + }) + setShowSuccess(true) + } else { + console.error('Contact form error:', data.error) + setShowError(true) + } } catch (error) { + console.error('Contact form submission error:', error) setShowError(true) } finally { setIsSubmitting(false) diff --git a/app/admin/mailgun/page.tsx b/app/admin/mailgun/page.tsx new file mode 100644 index 0000000..18cac2a --- /dev/null +++ b/app/admin/mailgun/page.tsx @@ -0,0 +1,437 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Box, + Paper, + Typography, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + Switch, + FormControlLabel, + Alert, + Divider, + Card, + CardContent, + InputAdornment, + IconButton, + Chip +} from '@mui/material' +import { + Save as SaveIcon, + PlayArrow as TestIcon, + Visibility, + VisibilityOff, + Email as EmailIcon, + Settings as SettingsIcon, + CheckCircle, + Error as ErrorIcon +} from '@mui/icons-material' + +interface MailgunSettings { + id?: string + apiKey?: string + domain: string + region: string + fromEmail: string + fromName: string + replyToEmail?: string + isEnabled: boolean + testMode: boolean + webhookUrl?: string + createdAt?: string + updatedAt?: string +} + +export default function MailgunSettingsPage() { + const [settings, setSettings] = useState({ + domain: '', + region: 'US', + fromEmail: '', + fromName: '', + isEnabled: false, + testMode: true + }) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [showApiKey, setShowApiKey] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + const [testEmail, setTestEmail] = useState('') + const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'connected' | 'error'>('unknown') + + useEffect(() => { + loadSettings() + }, []) + + const loadSettings = async () => { + setLoading(true) + try { + const response = await fetch('/api/admin/mailgun', { + credentials: 'include' + }) + + if (response.ok) { + const data = await response.json() + if (data.success && data.data) { + setSettings(data.data) + if (data.data.isEnabled) { + testConnection() + } + } + } + } catch (error) { + console.error('Error loading settings:', error) + setMessage({ type: 'error', text: 'Failed to load settings' }) + } finally { + setLoading(false) + } + } + + const handleSave = async () => { + setSaving(true) + setMessage(null) + + try { + const method = settings.id ? 'PUT' : 'POST' + const response = await fetch('/api/admin/mailgun', { + method, + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(settings) + }) + + const data = await response.json() + + if (response.ok && data.success) { + setSettings(data.data) + setMessage({ type: 'success', text: 'Settings saved successfully' }) + if (data.data.isEnabled) { + testConnection() + } + } else { + setMessage({ type: 'error', text: data.error || 'Failed to save settings' }) + } + } catch (error) { + console.error('Error saving settings:', error) + setMessage({ type: 'error', text: 'Failed to save settings' }) + } finally { + setSaving(false) + } + } + + const testConnection = async () => { + setTesting(true) + try { + const response = await fetch('/api/admin/mailgun/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ testType: 'connection' }) + }) + + const data = await response.json() + + if (data.success) { + setConnectionStatus('connected') + setMessage({ type: 'success', text: 'Connection test successful' }) + } else { + setConnectionStatus('error') + setMessage({ type: 'error', text: `Connection failed: ${data.error}` }) + } + } catch (error) { + setConnectionStatus('error') + setMessage({ type: 'error', text: 'Connection test failed' }) + } finally { + setTesting(false) + } + } + + const sendTestEmail = async () => { + if (!testEmail) { + setMessage({ type: 'error', text: 'Please enter a test email address' }) + return + } + + setTesting(true) + try { + const response = await fetch('/api/admin/mailgun/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ testType: 'email', email: testEmail }) + }) + + const data = await response.json() + + if (data.success) { + setMessage({ type: 'success', text: `Test email sent successfully! ${data.messageId ? `Message ID: ${data.messageId}` : ''}` }) + } else { + setMessage({ type: 'error', text: `Failed to send test email: ${data.error}` }) + } + } catch (error) { + setMessage({ type: 'error', text: 'Failed to send test email' }) + } finally { + setTesting(false) + } + } + + const handleChange = (field: keyof MailgunSettings, value: any) => { + setSettings(prev => ({ ...prev, [field]: value })) + } + + return ( + + + + + Mailgun Settings + + + + {message && ( + setMessage(null)} + > + {message.text} + + )} + + + {/* Connection Status */} + + + + + + Connection Status + + + {connectionStatus === 'connected' && ( + } + size="small" + /> + )} + {connectionStatus === 'error' && ( + } + size="small" + /> + )} + {connectionStatus === 'unknown' && ( + + )} + + + + + + + + {/* Main Settings */} + + + Mailgun Configuration + + + + handleChange('apiKey', e.target.value)} + placeholder="key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + fullWidth + InputProps={{ + endAdornment: ( + + setShowApiKey(!showApiKey)} + edge="end" + > + {showApiKey ? : } + + + ) + }} + helperText="Your Mailgun API key" + /> + + handleChange('domain', e.target.value)} + placeholder="mg.yourdomain.com" + fullWidth + helperText="Your Mailgun domain" + /> + + + Region + + + + + + Email Settings + + handleChange('fromEmail', e.target.value)} + placeholder="noreply@yourdomain.com" + fullWidth + helperText="Default sender email address" + /> + + handleChange('fromName', e.target.value)} + placeholder="Biblical Guide" + fullWidth + helperText="Default sender name" + /> + + handleChange('replyToEmail', e.target.value)} + placeholder="support@yourdomain.com" + fullWidth + helperText="Default reply-to address" + /> + + handleChange('webhookUrl', e.target.value)} + placeholder="https://yourdomain.com/api/webhooks/mailgun" + fullWidth + helperText="URL for Mailgun webhooks (tracking, bounces, etc.)" + /> + + + + + handleChange('isEnabled', e.target.checked)} + /> + } + label="Enable Mailgun" + /> + + handleChange('testMode', e.target.checked)} + /> + } + label="Test Mode (emails won't actually be sent)" + /> + + + + + + + {/* Test Email */} + + + + Test Email + + + + setTestEmail(e.target.value)} + placeholder="test@example.com" + fullWidth + /> + + + + + {settings.testMode && ( + + Test mode is enabled. Emails will be logged but not actually sent. + + )} + + + {settings.updatedAt && ( + + + Last updated: {new Date(settings.updatedAt).toLocaleString()} + + + )} + + + + + ) +} \ No newline at end of file diff --git a/app/api/admin/mailgun/route.ts b/app/api/admin/mailgun/route.ts new file mode 100644 index 0000000..b4d9799 --- /dev/null +++ b/app/api/admin/mailgun/route.ts @@ -0,0 +1,231 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAdminAuth } from '@/lib/admin-auth' +import { mailgunService } from '@/lib/mailgun' + +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + try { + const adminUser = await verifyAdminAuth(request) + if (!adminUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const settings = await prisma.mailgunSettings.findFirst({ + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + domain: true, + region: true, + fromEmail: true, + fromName: true, + replyToEmail: true, + isEnabled: true, + testMode: true, + webhookUrl: true, + createdAt: true, + updatedAt: true, + // Don't return the API key for security + apiKey: false + } + }) + + return NextResponse.json({ + success: true, + data: settings + }) + } catch (error) { + console.error('Error fetching Mailgun settings:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const adminUser = await verifyAdminAuth(request) + if (!adminUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { + apiKey, + domain, + region = 'US', + fromEmail, + fromName, + replyToEmail, + isEnabled = false, + testMode = true, + webhookUrl + } = body + + // Validate required fields + if (!apiKey || !domain || !fromEmail || !fromName) { + return NextResponse.json({ + error: 'API key, domain, from email, and from name are required' + }, { status: 400 }) + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(fromEmail)) { + return NextResponse.json({ + error: 'Invalid from email format' + }, { status: 400 }) + } + + if (replyToEmail && !emailRegex.test(replyToEmail)) { + return NextResponse.json({ + error: 'Invalid reply-to email format' + }, { status: 400 }) + } + + // Disable existing settings + await prisma.mailgunSettings.updateMany({ + data: { isEnabled: false } + }) + + // Create new settings + const settings = await prisma.mailgunSettings.create({ + data: { + apiKey, + domain, + region, + fromEmail, + fromName, + replyToEmail, + isEnabled, + testMode, + webhookUrl, + updatedBy: adminUser.id + } + }) + + // Clear service cache + mailgunService.clearCache() + + return NextResponse.json({ + success: true, + data: { + id: settings.id, + domain: settings.domain, + region: settings.region, + fromEmail: settings.fromEmail, + fromName: settings.fromName, + replyToEmail: settings.replyToEmail, + isEnabled: settings.isEnabled, + testMode: settings.testMode, + webhookUrl: settings.webhookUrl, + createdAt: settings.createdAt, + updatedAt: settings.updatedAt + } + }) + } catch (error) { + console.error('Error creating Mailgun settings:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function PUT(request: NextRequest) { + try { + const adminUser = await verifyAdminAuth(request) + if (!adminUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { + id, + apiKey, + domain, + region, + fromEmail, + fromName, + replyToEmail, + isEnabled, + testMode, + webhookUrl + } = body + + if (!id) { + return NextResponse.json({ error: 'Settings ID is required' }, { status: 400 }) + } + + // Validate required fields + if (!domain || !fromEmail || !fromName) { + return NextResponse.json({ + error: 'Domain, from email, and from name are required' + }, { status: 400 }) + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(fromEmail)) { + return NextResponse.json({ + error: 'Invalid from email format' + }, { status: 400 }) + } + + if (replyToEmail && !emailRegex.test(replyToEmail)) { + return NextResponse.json({ + error: 'Invalid reply-to email format' + }, { status: 400 }) + } + + // If enabling this setting, disable others + if (isEnabled) { + await prisma.mailgunSettings.updateMany({ + where: { id: { not: id } }, + data: { isEnabled: false } + }) + } + + // Build update data + const updateData: any = { + domain, + region, + fromEmail, + fromName, + replyToEmail, + isEnabled, + testMode, + webhookUrl, + updatedBy: adminUser.id + } + + // Only update API key if provided + if (apiKey) { + updateData.apiKey = apiKey + } + + const settings = await prisma.mailgunSettings.update({ + where: { id }, + data: updateData + }) + + // Clear service cache + mailgunService.clearCache() + + return NextResponse.json({ + success: true, + data: { + id: settings.id, + domain: settings.domain, + region: settings.region, + fromEmail: settings.fromEmail, + fromName: settings.fromName, + replyToEmail: settings.replyToEmail, + isEnabled: settings.isEnabled, + testMode: settings.testMode, + webhookUrl: settings.webhookUrl, + createdAt: settings.createdAt, + updatedAt: settings.updatedAt + } + }) + } catch (error) { + console.error('Error updating Mailgun settings:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/admin/mailgun/test/route.ts b/app/api/admin/mailgun/test/route.ts new file mode 100644 index 0000000..6058ef7 --- /dev/null +++ b/app/api/admin/mailgun/test/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server' +import { verifyAdminAuth } from '@/lib/admin-auth' +import { mailgunService } from '@/lib/mailgun' + +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + try { + const adminUser = await verifyAdminAuth(request) + if (!adminUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { testType, email } = body + + if (testType === 'connection') { + // Test connection to Mailgun + const result = await mailgunService.testConnection() + return NextResponse.json(result) + } + + if (testType === 'email') { + // Send test email + if (!email) { + return NextResponse.json({ + success: false, + error: 'Email address is required for email test' + }, { status: 400 }) + } + + const result = await mailgunService.sendEmail({ + to: email, + subject: 'Biblical Guide - Email Configuration Test', + text: `Hello, + +This is a configuration test email from Biblical Guide. + +Your email system has been successfully configured and is working properly. You can now receive important notifications from our platform. + +Best regards, +The Biblical Guide Team + +--- +This is an automated message. Please do not reply to this email. +Time: ${new Date().toLocaleString()}`, + html: ` + + + + + + Biblical Guide - Email Configuration Test + + +
+

Biblical Guide

+

Email Configuration Test

+
+ +
+

Hello,

+ +

This is a configuration test email from Biblical Guide.

+ +

Your email system has been successfully configured and is working properly. You can now receive important notifications from our platform.

+ +
+

✓ Configuration successful

+
+ +

Best regards,
+ The Biblical Guide Team

+
+ +
+

This is an automated message. Please do not reply to this email.

+

Time: ${new Date().toLocaleString()}

+
+ + + ` + }) + + return NextResponse.json(result) + } + + return NextResponse.json({ + success: false, + error: 'Invalid test type' + }, { status: 400 }) + + } catch (error) { + console.error('Error testing Mailgun:', error) + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : 'Test failed' + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/bookmarks/all/route.ts b/app/api/bookmarks/all/route.ts index cb548c3..2f391aa 100644 --- a/app/api/bookmarks/all/route.ts +++ b/app/api/bookmarks/all/route.ts @@ -86,12 +86,13 @@ export async function GET(request: Request) { id: bookmark.id, type: 'chapter' as const, title: `${bookmark.book.name} ${bookmark.chapterNum}`, - subtitle: bookmark.book.version.name, + subtitle: `${bookmark.book.version.name} - ${bookmark.book.version.abbreviation}`, note: bookmark.note, createdAt: bookmark.createdAt, navigation: { bookId: bookmark.bookId, - chapterNum: bookmark.chapterNum + chapterNum: bookmark.chapterNum, + versionId: bookmark.book.versionId }, book: bookmark.book })) @@ -100,7 +101,7 @@ export async function GET(request: Request) { id: bookmark.id, type: 'verse' as const, title: `${bookmark.verse.chapter.book.name} ${bookmark.verse.chapter.chapterNum}:${bookmark.verse.verseNum}`, - subtitle: bookmark.verse.chapter.book.version.name, + subtitle: `${bookmark.verse.chapter.book.version.name} - ${bookmark.verse.chapter.book.version.abbreviation}`, note: bookmark.note, createdAt: bookmark.createdAt, color: bookmark.color, @@ -108,7 +109,8 @@ export async function GET(request: Request) { navigation: { bookId: bookmark.verse.chapter.bookId, chapterNum: bookmark.verse.chapter.chapterNum, - verseNum: bookmark.verse.verseNum + verseNum: bookmark.verse.verseNum, + versionId: bookmark.verse.chapter.book.versionId }, verse: bookmark.verse })) diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..b077430 --- /dev/null +++ b/app/api/contact/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server' +import { mailgunService } from '@/lib/mailgun' +import { z } from 'zod' + +export const runtime = 'nodejs' + +const contactSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + email: z.string().email('Invalid email address'), + subject: z.string().min(1, 'Subject is required').max(200), + message: z.string().min(10, 'Message must be at least 10 characters').max(5000) +}) + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + // Validate input + const validationResult = contactSchema.safeParse(body) + if (!validationResult.success) { + return NextResponse.json({ + success: false, + error: 'Invalid form data', + details: validationResult.error.errors + }, { status: 400 }) + } + + const { name, email, subject, message } = validationResult.data + + // Basic spam prevention - check for common spam indicators + const spamIndicators = [ + message.includes('http://'), + message.includes('https://'), + message.includes('www.'), + message.includes('bitcoin'), + message.includes('cryptocurrency'), + message.length < 10, + name.length < 2 + ] + + const spamScore = spamIndicators.filter(Boolean).length + if (spamScore >= 2) { + return NextResponse.json({ + success: false, + error: 'Message flagged as potential spam' + }, { status: 400 }) + } + + // Send email using Mailgun + const emailResult = await mailgunService.sendContactForm({ + name, + email, + subject, + message + }) + + if (emailResult.success) { + return NextResponse.json({ + success: true, + message: 'Your message has been sent successfully!' + }) + } else { + console.error('Contact form email failed:', emailResult.error) + return NextResponse.json({ + success: false, + error: 'Failed to send message. Please try again later.' + }, { status: 500 }) + } + + } catch (error) { + console.error('Contact form error:', error) + return NextResponse.json({ + success: false, + error: 'Internal server error' + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/components/admin/layout/admin-layout.tsx b/components/admin/layout/admin-layout.tsx index a7a775c..49dc57d 100644 --- a/components/admin/layout/admin-layout.tsx +++ b/components/admin/layout/admin-layout.tsx @@ -33,7 +33,8 @@ import { AdminPanelSettings, Launch as LaunchIcon, Article as PageIcon, - Share + Share, + Email as EmailIcon } from '@mui/icons-material'; interface AdminLayoutProps { @@ -53,6 +54,7 @@ const menuItems = [ { text: 'Users', icon: People, href: '/admin/users' }, { text: 'Pages', icon: PageIcon, href: '/admin/pages' }, { text: 'Social Media', icon: Share, href: '/admin/social-media' }, + { text: 'Email Settings', icon: EmailIcon, href: '/admin/mailgun' }, { text: 'Content Moderation', icon: Gavel, href: '/admin/content' }, { text: 'Analytics', icon: Analytics, href: '/admin/analytics' }, { text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' }, diff --git a/lib/mailgun.ts b/lib/mailgun.ts new file mode 100644 index 0000000..6e89964 --- /dev/null +++ b/lib/mailgun.ts @@ -0,0 +1,228 @@ +import Mailgun from 'mailgun.js' +import FormData from 'form-data' +import { prisma } from './db' + +interface EmailOptions { + to: string | string[] + subject: string + text?: string + html?: string + from?: string + replyTo?: string +} + +interface SendEmailResult { + success: boolean + messageId?: string + error?: string +} + +class MailgunService { + private mailgun: any + private settings: any = null + + constructor() { + this.mailgun = new Mailgun(FormData) + } + + private async getSettings() { + if (!this.settings) { + this.settings = await prisma.mailgunSettings.findFirst({ + where: { isEnabled: true }, + orderBy: { updatedAt: 'desc' } + }) + } + return this.settings + } + + private getMg() { + const settings = this.settings + if (!settings) { + throw new Error('No Mailgun settings found') + } + + return this.mailgun.client({ + username: 'api', + key: settings.apiKey, + url: settings.region === 'EU' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net' + }) + } + + async sendEmail(options: EmailOptions): Promise { + try { + const settings = await this.getSettings() + + if (!settings) { + return { + success: false, + error: 'Mailgun not configured' + } + } + + if (!settings.isEnabled) { + return { + success: false, + error: 'Mailgun is disabled' + } + } + + const mg = this.getMg() + + const emailData = { + from: options.from || `${settings.fromName} <${settings.fromEmail}>`, + to: Array.isArray(options.to) ? options.to.join(', ') : options.to, + subject: options.subject, + text: options.text, + html: options.html, + 'h:Reply-To': options.replyTo || settings.replyToEmail + } + + // Remove undefined values + Object.keys(emailData).forEach(key => { + if (emailData[key as keyof typeof emailData] === undefined) { + delete emailData[key as keyof typeof emailData] + } + }) + + // In test mode, use Mailgun's test domain + if (settings.testMode) { + console.log('Mailgun test mode - email would be sent:', emailData) + return { + success: true, + messageId: `test-${Date.now()}` + } + } + + const response = await mg.messages.create(settings.domain, emailData) + + return { + success: true, + messageId: response.id + } + } catch (error) { + console.error('Mailgun send error:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + async sendContactForm(data: { + name: string + email: string + subject: string + message: string + }): Promise { + const settings = await this.getSettings() + + if (!settings) { + return { + success: false, + error: 'Mailgun not configured' + } + } + + const html = ` +

New Contact Form Submission

+

Name: ${data.name}

+

Email: ${data.email}

+

Subject: ${data.subject}

+

Message:

+
+ ${data.message.replace(/\n/g, '
')} +
+ ` + + const text = ` +New Contact Form Submission + +Name: ${data.name} +Email: ${data.email} +Subject: ${data.subject} + +Message: +${data.message} + ` + + return this.sendEmail({ + to: settings.fromEmail, // Send to site admin + subject: `Contact Form: ${data.subject}`, + html, + text, + replyTo: data.email + }) + } + + async sendPasswordReset(email: string, resetToken: string): Promise { + const settings = await this.getSettings() + + if (!settings) { + return { + success: false, + error: 'Mailgun not configured' + } + } + + // This would need to be updated with your actual domain + const resetUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/reset-password?token=${resetToken}` + + const html = ` +

Password Reset Request

+

You requested to reset your password for your Biblical Guide account.

+

Click the link below to reset your password:

+

Reset Password

+

This link will expire in 1 hour.

+

If you didn't request this, please ignore this email.

+ ` + + const text = ` +Password Reset Request + +You requested to reset your password for your Biblical Guide account. + +Click the link below to reset your password: +${resetUrl} + +This link will expire in 1 hour. + +If you didn't request this, please ignore this email. + ` + + return this.sendEmail({ + to: email, + subject: 'Reset Your Password - Biblical Guide', + html, + text + }) + } + + async testConnection(): Promise<{ success: boolean; error?: string }> { + try { + const settings = await this.getSettings() + + if (!settings) { + return { success: false, error: 'No settings found' } + } + + const mg = this.getMg() + + // Test by getting domain info + await mg.domains.get(settings.domain) + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Connection failed' + } + } + } + + // Clear cached settings when they are updated + clearCache() { + this.settings = null + } +} + +export const mailgunService = new MailgunService() \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a9b1531..7916e39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,9 +46,11 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "form-data": "^4.0.4", "jsonwebtoken": "^9.0.2", "lexical": "^0.35.0", "lucide-react": "^0.544.0", + "mailgun.js": "^12.0.3", "negotiator": "^1.0.0", "next": "^15.5.3", "next-intl": "^4.3.9", @@ -3800,6 +3802,12 @@ "node": ">=10" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -3837,6 +3845,17 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -3862,6 +3881,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -3962,6 +3987,19 @@ } } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4101,6 +4139,18 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -4391,6 +4441,15 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4456,6 +4515,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -4630,6 +4703,51 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.39.10", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", @@ -4759,6 +4877,42 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -4796,6 +4950,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4805,6 +4983,19 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -4835,12 +5026,51 @@ "giget": "dist/cli.mjs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5491,6 +5721,20 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mailgun.js": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/mailgun.js/-/mailgun.js-12.0.3.tgz", + "integrity": "sha512-ki4+xNDA/MjIQAWHB2TK5t5lJN99l45iIRU4SmTqhkY2RCfwQD04s59R3I5YCKdkY/Y5598XRKdkQOj29+eydw==", + "license": "MIT", + "dependencies": { + "axios": "^1.10.0", + "base-64": "^1.0.0", + "url-join": "^4.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5501,6 +5745,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -6975,6 +7228,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -7930,6 +8189,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", diff --git a/package.json b/package.json index 0d06f93..1505a9d 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,11 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "form-data": "^4.0.4", "jsonwebtoken": "^9.0.2", "lexical": "^0.35.0", "lucide-react": "^0.544.0", + "mailgun.js": "^12.0.3", "negotiator": "^1.0.0", "next": "^15.5.3", "next-intl": "^4.3.9", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1cf7c13..bf890e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,6 +34,7 @@ model User { uploadedFiles MediaFile[] createdSocialMedia SocialMediaLink[] @relation("SocialMediaCreator") updatedSocialMedia SocialMediaLink[] @relation("SocialMediaUpdater") + updatedMailgunSettings MailgunSettings[] @relation("MailgunSettingsUpdater") @@index([role]) } @@ -369,4 +370,24 @@ model SocialMediaLink { @@unique([platform]) @@index([isEnabled, order]) +} + +model MailgunSettings { + id String @id @default(uuid()) + apiKey String // Encrypted Mailgun API key + domain String // Mailgun domain (e.g., mg.yourdomain.com) + region String @default("US") // US or EU + fromEmail String // Default from email address + fromName String // Default from name + replyToEmail String? // Optional reply-to address + isEnabled Boolean @default(false) + testMode Boolean @default(true) + webhookUrl String? // Mailgun webhook URL for tracking + updatedBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + updater User @relation("MailgunSettingsUpdater", fields: [updatedBy], references: [id]) + + @@index([isEnabled]) } \ No newline at end of file