Add Mailgun admin tools and contact API
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import { useAuth } from '@/hooks/use-auth'
|
import { useAuth } from '@/hooks/use-auth'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -126,6 +126,7 @@ export default function BibleReaderNew() {
|
|||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
const t = useTranslations('pages.bible')
|
const t = useTranslations('pages.bible')
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
@@ -261,6 +262,68 @@ export default function BibleReaderNew() {
|
|||||||
})
|
})
|
||||||
}, [locale, showAllVersions, selectedVersion])
|
}, [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
|
// Fetch books when version changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedVersion) {
|
if (selectedVersion) {
|
||||||
@@ -431,16 +494,6 @@ export default function BibleReaderNew() {
|
|||||||
const currentBook = books.find(book => book.id === selectedBook)
|
const currentBook = books.find(book => book.id === selectedBook)
|
||||||
const maxChapters = currentBook?.chapters?.length || 1
|
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 scrollToVerse = (verseNum: number) => {
|
||||||
const verseElement = verseRefs.current[verseNum]
|
const verseElement = verseRefs.current[verseNum]
|
||||||
if (verseElement) {
|
if (verseElement) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface BookmarkItem {
|
|||||||
bookId: string
|
bookId: string
|
||||||
chapterNum: number
|
chapterNum: number
|
||||||
verseNum?: number
|
verseNum?: number
|
||||||
|
versionId: string
|
||||||
}
|
}
|
||||||
verse?: {
|
verse?: {
|
||||||
id: string
|
id: string
|
||||||
@@ -118,7 +119,8 @@ export default function BookmarksPage() {
|
|||||||
const handleNavigateToBookmark = (bookmark: BookmarkItem) => {
|
const handleNavigateToBookmark = (bookmark: BookmarkItem) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
book: bookmark.navigation.bookId,
|
book: bookmark.navigation.bookId,
|
||||||
chapter: bookmark.navigation.chapterNum.toString()
|
chapter: bookmark.navigation.chapterNum.toString(),
|
||||||
|
version: bookmark.navigation.versionId
|
||||||
})
|
})
|
||||||
|
|
||||||
if (bookmark.navigation.verseNum) {
|
if (bookmark.navigation.verseNum) {
|
||||||
|
|||||||
@@ -50,20 +50,30 @@ export default function Contact() {
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate form submission
|
const response = await fetch('/api/contact', {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
// Here you would typically send the data to your API
|
'Content-Type': 'application/json',
|
||||||
console.log('Form submitted:', formData)
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
subject: '',
|
|
||||||
message: ''
|
|
||||||
})
|
})
|
||||||
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) {
|
} catch (error) {
|
||||||
|
console.error('Contact form submission error:', error)
|
||||||
setShowError(true)
|
setShowError(true)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
|
|||||||
437
app/admin/mailgun/page.tsx
Normal file
437
app/admin/mailgun/page.tsx
Normal file
@@ -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<MailgunSettings>({
|
||||||
|
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 (
|
||||||
|
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||||
|
<EmailIcon sx={{ mr: 2, color: 'primary.main' }} />
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Mailgun Settings
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<Alert
|
||||||
|
severity={message.type}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
onClose={() => setMessage(null)}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Connection Status */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<SettingsIcon sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">Connection Status</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{connectionStatus === 'connected' && (
|
||||||
|
<Chip
|
||||||
|
label="Connected"
|
||||||
|
color="success"
|
||||||
|
icon={<CheckCircle />}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{connectionStatus === 'error' && (
|
||||||
|
<Chip
|
||||||
|
label="Connection Error"
|
||||||
|
color="error"
|
||||||
|
icon={<ErrorIcon />}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{connectionStatus === 'unknown' && (
|
||||||
|
<Chip
|
||||||
|
label="Unknown"
|
||||||
|
color="default"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={testing || !settings.isEnabled}
|
||||||
|
startIcon={<TestIcon />}
|
||||||
|
>
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 3, flexDirection: { xs: 'column', md: 'row' } }}>
|
||||||
|
{/* Main Settings */}
|
||||||
|
<Paper sx={{ p: 3, flex: { xs: '1 1 100%', md: '2 1 0' } }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||||
|
Mailgun Configuration
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<TextField
|
||||||
|
label="API Key"
|
||||||
|
type={showApiKey ? 'text' : 'password'}
|
||||||
|
value={settings.apiKey || ''}
|
||||||
|
onChange={(e) => handleChange('apiKey', e.target.value)}
|
||||||
|
placeholder="key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showApiKey ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
helperText="Your Mailgun API key"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
value={settings.domain}
|
||||||
|
onChange={(e) => handleChange('domain', e.target.value)}
|
||||||
|
placeholder="mg.yourdomain.com"
|
||||||
|
fullWidth
|
||||||
|
helperText="Your Mailgun domain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Region</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={settings.region}
|
||||||
|
onChange={(e) => handleChange('region', e.target.value)}
|
||||||
|
label="Region"
|
||||||
|
>
|
||||||
|
<MenuItem value="US">US</MenuItem>
|
||||||
|
<MenuItem value="EU">EU</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Typography variant="h6">Email Settings</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="From Email"
|
||||||
|
type="email"
|
||||||
|
value={settings.fromEmail}
|
||||||
|
onChange={(e) => handleChange('fromEmail', e.target.value)}
|
||||||
|
placeholder="noreply@yourdomain.com"
|
||||||
|
fullWidth
|
||||||
|
helperText="Default sender email address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="From Name"
|
||||||
|
value={settings.fromName}
|
||||||
|
onChange={(e) => handleChange('fromName', e.target.value)}
|
||||||
|
placeholder="Biblical Guide"
|
||||||
|
fullWidth
|
||||||
|
helperText="Default sender name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Reply-To Email (Optional)"
|
||||||
|
type="email"
|
||||||
|
value={settings.replyToEmail || ''}
|
||||||
|
onChange={(e) => handleChange('replyToEmail', e.target.value)}
|
||||||
|
placeholder="support@yourdomain.com"
|
||||||
|
fullWidth
|
||||||
|
helperText="Default reply-to address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Webhook URL (Optional)"
|
||||||
|
type="url"
|
||||||
|
value={settings.webhookUrl || ''}
|
||||||
|
onChange={(e) => handleChange('webhookUrl', e.target.value)}
|
||||||
|
placeholder="https://yourdomain.com/api/webhooks/mailgun"
|
||||||
|
fullWidth
|
||||||
|
helperText="URL for Mailgun webhooks (tracking, bounces, etc.)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.isEnabled}
|
||||||
|
onChange={(e) => handleChange('isEnabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable Mailgun"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.testMode}
|
||||||
|
onChange={(e) => handleChange('testMode', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Test Mode (emails won't actually be sent)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || loading}
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
size="large"
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Test Email */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, flex: { xs: '1 1 100%', md: '1 1 0' } }}>
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||||
|
Test Email
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Test Email Address"
|
||||||
|
type="email"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
placeholder="test@example.com"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={sendTestEmail}
|
||||||
|
disabled={testing || !settings.isEnabled || !testEmail}
|
||||||
|
startIcon={<EmailIcon />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{testing ? 'Sending...' : 'Send Test Email'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{settings.testMode && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
|
Test mode is enabled. Emails will be logged but not actually sent.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{settings.updatedAt && (
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Last updated: {new Date(settings.updatedAt).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
app/api/admin/mailgun/route.ts
Normal file
231
app/api/admin/mailgun/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/api/admin/mailgun/test/route.ts
Normal file
100
app/api/admin/mailgun/test/route.ts
Normal file
@@ -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: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Biblical Guide - Email Configuration Test</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<h2 style="color: #2c3e50; margin: 0;">Biblical Guide</h2>
|
||||||
|
<p style="margin: 5px 0 0 0; color: #6c757d;">Email Configuration Test</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: white; padding: 20px; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>This is a configuration test email from Biblical Guide.</p>
|
||||||
|
|
||||||
|
<p><strong>Your email system has been successfully configured and is working properly.</strong> You can now receive important notifications from our platform.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; padding: 15px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #155724;"><strong>✓ Configuration successful</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
The Biblical Guide Team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; padding: 15px; background-color: #f8f9fa; border-radius: 8px; font-size: 12px; color: #6c757d;">
|
||||||
|
<p style="margin: 0;">This is an automated message. Please do not reply to this email.</p>
|
||||||
|
<p style="margin: 5px 0 0 0;">Time: ${new Date().toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,12 +86,13 @@ export async function GET(request: Request) {
|
|||||||
id: bookmark.id,
|
id: bookmark.id,
|
||||||
type: 'chapter' as const,
|
type: 'chapter' as const,
|
||||||
title: `${bookmark.book.name} ${bookmark.chapterNum}`,
|
title: `${bookmark.book.name} ${bookmark.chapterNum}`,
|
||||||
subtitle: bookmark.book.version.name,
|
subtitle: `${bookmark.book.version.name} - ${bookmark.book.version.abbreviation}`,
|
||||||
note: bookmark.note,
|
note: bookmark.note,
|
||||||
createdAt: bookmark.createdAt,
|
createdAt: bookmark.createdAt,
|
||||||
navigation: {
|
navigation: {
|
||||||
bookId: bookmark.bookId,
|
bookId: bookmark.bookId,
|
||||||
chapterNum: bookmark.chapterNum
|
chapterNum: bookmark.chapterNum,
|
||||||
|
versionId: bookmark.book.versionId
|
||||||
},
|
},
|
||||||
book: bookmark.book
|
book: bookmark.book
|
||||||
}))
|
}))
|
||||||
@@ -100,7 +101,7 @@ export async function GET(request: Request) {
|
|||||||
id: bookmark.id,
|
id: bookmark.id,
|
||||||
type: 'verse' as const,
|
type: 'verse' as const,
|
||||||
title: `${bookmark.verse.chapter.book.name} ${bookmark.verse.chapter.chapterNum}:${bookmark.verse.verseNum}`,
|
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,
|
note: bookmark.note,
|
||||||
createdAt: bookmark.createdAt,
|
createdAt: bookmark.createdAt,
|
||||||
color: bookmark.color,
|
color: bookmark.color,
|
||||||
@@ -108,7 +109,8 @@ export async function GET(request: Request) {
|
|||||||
navigation: {
|
navigation: {
|
||||||
bookId: bookmark.verse.chapter.bookId,
|
bookId: bookmark.verse.chapter.bookId,
|
||||||
chapterNum: bookmark.verse.chapter.chapterNum,
|
chapterNum: bookmark.verse.chapter.chapterNum,
|
||||||
verseNum: bookmark.verse.verseNum
|
verseNum: bookmark.verse.verseNum,
|
||||||
|
versionId: bookmark.verse.chapter.book.versionId
|
||||||
},
|
},
|
||||||
verse: bookmark.verse
|
verse: bookmark.verse
|
||||||
}))
|
}))
|
||||||
|
|||||||
77
app/api/contact/route.ts
Normal file
77
app/api/contact/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ import {
|
|||||||
AdminPanelSettings,
|
AdminPanelSettings,
|
||||||
Launch as LaunchIcon,
|
Launch as LaunchIcon,
|
||||||
Article as PageIcon,
|
Article as PageIcon,
|
||||||
Share
|
Share,
|
||||||
|
Email as EmailIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
@@ -53,6 +54,7 @@ const menuItems = [
|
|||||||
{ text: 'Users', icon: People, href: '/admin/users' },
|
{ text: 'Users', icon: People, href: '/admin/users' },
|
||||||
{ text: 'Pages', icon: PageIcon, href: '/admin/pages' },
|
{ text: 'Pages', icon: PageIcon, href: '/admin/pages' },
|
||||||
{ text: 'Social Media', icon: Share, href: '/admin/social-media' },
|
{ 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: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
||||||
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
||||||
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
||||||
|
|||||||
228
lib/mailgun.ts
Normal file
228
lib/mailgun.ts
Normal file
@@ -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<SendEmailResult> {
|
||||||
|
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<SendEmailResult> {
|
||||||
|
const settings = await this.getSettings()
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Mailgun not configured'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<h2>New Contact Form Submission</h2>
|
||||||
|
<p><strong>Name:</strong> ${data.name}</p>
|
||||||
|
<p><strong>Email:</strong> ${data.email}</p>
|
||||||
|
<p><strong>Subject:</strong> ${data.subject}</p>
|
||||||
|
<p><strong>Message:</strong></p>
|
||||||
|
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px;">
|
||||||
|
${data.message.replace(/\n/g, '<br>')}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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<SendEmailResult> {
|
||||||
|
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 = `
|
||||||
|
<h2>Password Reset Request</h2>
|
||||||
|
<p>You requested to reset your password for your Biblical Guide account.</p>
|
||||||
|
<p>Click the link below to reset your password:</p>
|
||||||
|
<p><a href="${resetUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Reset Password</a></p>
|
||||||
|
<p>This link will expire in 1 hour.</p>
|
||||||
|
<p>If you didn't request this, please ignore this email.</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
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()
|
||||||
265
package-lock.json
generated
265
package-lock.json
generated
@@ -46,9 +46,11 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lexical": "^0.35.0",
|
"lexical": "^0.35.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"mailgun.js": "^12.0.3",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"next-intl": "^4.3.9",
|
"next-intl": "^4.3.9",
|
||||||
@@ -3800,6 +3802,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.21",
|
"version": "10.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||||
@@ -3837,6 +3845,17 @@
|
|||||||
"postcss": "^8.1.0"
|
"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": {
|
"node_modules/babel-plugin-macros": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/base64id": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
"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": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -4101,6 +4139,18 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/comma-separated-tokens": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||||
@@ -4391,6 +4441,15 @@
|
|||||||
"robust-predicates": "^3.0.2"
|
"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": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
@@ -4456,6 +4515,20 @@
|
|||||||
"url": "https://dotenvx.com"
|
"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": {
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
@@ -4630,6 +4703,51 @@
|
|||||||
"is-arrayish": "^0.2.1"
|
"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": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.39.10",
|
"version": "1.39.10",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
||||||
@@ -4759,6 +4877,42 @@
|
|||||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@@ -4796,6 +4950,30 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-nonce": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
@@ -4805,6 +4983,19 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.10.1",
|
"version": "4.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||||
@@ -4835,12 +5026,51 @@
|
|||||||
"giget": "dist/cli.mjs"
|
"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": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -5491,6 +5721,20 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/markdown-table": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||||
@@ -5501,6 +5745,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/mdast-util-find-and-replace": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
"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"
|
"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": {
|
"node_modules/pure-rand": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
@@ -7930,6 +8189,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/use-callback-ref": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
|||||||
@@ -59,9 +59,11 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lexical": "^0.35.0",
|
"lexical": "^0.35.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"mailgun.js": "^12.0.3",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.3",
|
||||||
"next-intl": "^4.3.9",
|
"next-intl": "^4.3.9",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ model User {
|
|||||||
uploadedFiles MediaFile[]
|
uploadedFiles MediaFile[]
|
||||||
createdSocialMedia SocialMediaLink[] @relation("SocialMediaCreator")
|
createdSocialMedia SocialMediaLink[] @relation("SocialMediaCreator")
|
||||||
updatedSocialMedia SocialMediaLink[] @relation("SocialMediaUpdater")
|
updatedSocialMedia SocialMediaLink[] @relation("SocialMediaUpdater")
|
||||||
|
updatedMailgunSettings MailgunSettings[] @relation("MailgunSettingsUpdater")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
}
|
}
|
||||||
@@ -370,3 +371,23 @@ model SocialMediaLink {
|
|||||||
@@unique([platform])
|
@@unique([platform])
|
||||||
@@index([isEnabled, order])
|
@@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])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user