This commit is contained in:
2025-12-29 10:34:28 +01:00
commit 0347ee1342
35 changed files with 9593 additions and 0 deletions

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# API Configuration
VITE_API_URL=http://localhost:3001

21
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Dependencies
node_modules/
# Production
dist/
build/
# Environment
.env
.env.local
.env.production
# Logs
*.log
# OS
.DS_Store
# IDE
.vscode/
.idea/

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Email Configuration Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2870
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "email-config-ui",
"version": "1.0.0",
"description": "Modern Email Configuration UI",
"private": true,
"scripts": {
"dev": "vite --port 3008",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.2",
"react-icons": "^4.12.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

387
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,387 @@
import React, { useState, useEffect } from 'react';
import { FiSearch, FiRefreshCw, FiTrash2, FiList } from 'react-icons/fi';
import Header from './components/Header';
import OutOfOffice from './components/OutOfOffice';
import Forwarding from './components/Forwarding';
import Toast from './components/Toast';
import { emailRulesAPI } from './services/api';
function App() {
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [currentRule, setCurrentRule] = useState(null);
const [activeTab, setActiveTab] = useState('ooo');
const [isLoading, setIsLoading] = useState(false);
const [allRules, setAllRules] = useState([]);
const [showAllRules, setShowAllRules] = useState(false);
const [toast, setToast] = useState(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [singleEmailMode, setSingleEmailMode] = useState(false);
const showToast = (message, type = 'success') => {
setToast({ message, type });
};
// Check for authentication token in URL params on mount
useEffect(() => {
const checkAuthToken = async () => {
const params = new URLSearchParams(window.location.search);
const emailParam = params.get('email');
const expiresParam = params.get('expires');
const signatureParam = params.get('signature');
if (emailParam && expiresParam && signatureParam) {
setIsAuthenticating(true);
setSingleEmailMode(true); // Enable single email mode
try {
// Validate token
const result = await emailRulesAPI.validateToken(
emailParam,
expiresParam,
signatureParam
);
if (result.success) {
// Token valid - auto-fill email and load rule
setEmail(emailParam);
// Clean URL (remove token parameters)
window.history.replaceState({}, document.title, window.location.pathname);
// Auto-load the rule
const rule = await emailRulesAPI.getRule(emailParam);
setCurrentRule(rule);
showToast('Authenticated successfully from Roundcube', 'success');
}
} catch (error) {
if (error.statusCode === 404 || error.message.includes('not found') || error.message.includes('No rule exists')) {
// No rule exists yet - create empty template
setEmail(emailParam);
setCurrentRule({
email_address: emailParam,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
});
showToast('Welcome! You can now create email rules for your account.', 'success');
} else {
showToast('Authentication failed: ' + error.message, 'error');
}
} finally {
setIsAuthenticating(false);
}
}
};
checkAuthToken();
}, []);
const validateEmail = (email) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
const handleSearch = async () => {
const trimmedEmail = email.trim();
if (!trimmedEmail) {
setEmailError('Email address is required');
return;
}
if (!validateEmail(trimmedEmail)) {
setEmailError('Please enter a valid email address');
return;
}
setIsLoading(true);
setEmailError('');
try {
const rule = await emailRulesAPI.getRule(trimmedEmail);
setCurrentRule(rule);
showToast('Email rule loaded successfully', 'success');
} catch (error) {
if (error.statusCode === 404 || error.message.includes('not found') || error.message.includes('No rule exists')) {
setCurrentRule({
email_address: trimmedEmail,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
});
showToast('No existing rule found. You can create a new one.', 'warning');
} else {
showToast('Failed to fetch email rule: ' + error.message, 'error');
}
} finally {
setIsLoading(false);
}
};
const handleUpdate = async (updates) => {
if (!currentRule) return;
try {
const updatedData = {
email_address: currentRule.email_address,
...currentRule,
...updates,
};
await emailRulesAPI.createOrUpdateRule(updatedData);
setCurrentRule(updatedData);
showToast('Email rule updated successfully', 'success');
} catch (error) {
showToast('Failed to update email rule: ' + error.message, 'error');
}
};
const handleDelete = async () => {
if (!currentRule || !window.confirm(`Are you sure you want to delete the rule for ${currentRule.email_address}?`)) {
return;
}
try {
await emailRulesAPI.deleteRule(currentRule.email_address);
setCurrentRule(null);
setEmail('');
showToast('Email rule deleted successfully', 'success');
fetchAllRules();
} catch (error) {
showToast('Failed to delete email rule: ' + error.message, 'error');
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const fetchAllRules = async () => {
try {
const data = await emailRulesAPI.getAllRules();
setAllRules(data.rules || []);
} catch (error) {
console.error('Failed to fetch all rules:', error);
}
};
const handleSelectRule = (ruleEmail) => {
setEmail(ruleEmail);
setShowAllRules(false);
handleSearch();
};
useEffect(() => {
fetchAllRules();
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<Header />
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Single Email Mode Header */}
{singleEmailMode && email && (
<div className="card mb-8 bg-gradient-to-r from-primary-50 to-primary-100 border-primary-200">
<div className="flex items-center gap-4">
<div className="bg-primary-600 text-white rounded-full w-12 h-12 flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900">Your Email Configuration</h2>
<p className="text-sm text-primary-700 font-medium mt-1">{email}</p>
</div>
</div>
</div>
)}
{/* Email Search Section - Only show if NOT in single email mode */}
{!singleEmailMode && (
<>
<div className="card mb-8">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">Manage Email Rules</h2>
<p className="text-sm text-gray-600 mt-1">
Search for an email address to configure auto-replies and forwarding
</p>
</div>
<button
onClick={() => setShowAllRules(!showAllRules)}
className="btn-secondary flex items-center gap-2"
>
<FiList className="w-4 h-4" />
All Rules ({allRules.length})
</button>
</div>
<div className="flex gap-2">
<div className="flex-1">
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setEmailError('');
}}
onKeyPress={handleKeyPress}
placeholder="Enter email address (e.g., user@example.com)"
className={`input-field ${emailError ? 'border-red-500 focus:ring-red-500' : ''}`}
/>
{emailError && (
<p className="mt-1 text-sm text-red-600">{emailError}</p>
)}
</div>
<button
onClick={handleSearch}
disabled={isLoading}
className="btn-primary flex items-center gap-2 whitespace-nowrap"
>
{isLoading ? (
<>
<FiRefreshCw className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
<>
<FiSearch className="w-4 h-4" />
Search
</>
)}
</button>
</div>
</div>
{/* All Rules List */}
{showAllRules && allRules.length > 0 && (
<div className="card mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">All Configured Rules</h3>
<div className="space-y-2 max-h-96 overflow-y-auto">
{allRules.map((rule, index) => (
<div
key={index}
onClick={() => handleSelectRule(rule.email_address)}
className="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg cursor-pointer transition-colors"
>
<div>
<p className="font-medium text-gray-900">{rule.email_address}</p>
<div className="flex gap-3 mt-1 text-xs text-gray-600">
<span className={rule.ooo_active ? 'text-green-600 font-semibold' : ''}>
OOO: {rule.ooo_active ? 'Active' : 'Inactive'}
</span>
<span>Forwards: {rule.forwards?.length || 0}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{/* Configuration Tabs */}
{currentRule && (
<div className="card">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-bold text-gray-900">
{currentRule.email_address}
</h3>
<p className="text-sm text-gray-600 mt-1">
Configure email rules for this address
</p>
</div>
{!singleEmailMode && (
<button
onClick={handleDelete}
className="btn-danger flex items-center gap-2"
>
<FiTrash2 className="w-4 h-4" />
Delete Rule
</button>
)}
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex gap-8">
<button
onClick={() => setActiveTab('ooo')}
className={`pb-3 px-1 transition-colors ${
activeTab === 'ooo' ? 'tab-active' : 'tab-inactive'
}`}
>
Out of Office
</button>
<button
onClick={() => setActiveTab('forwarding')}
className={`pb-3 px-1 transition-colors ${
activeTab === 'forwarding' ? 'tab-active' : 'tab-inactive'
}`}
>
Email Forwarding
</button>
</nav>
</div>
{/* Tab Content */}
<div>
{activeTab === 'ooo' && (
<OutOfOffice rule={currentRule} onUpdate={handleUpdate} />
)}
{activeTab === 'forwarding' && (
<Forwarding rule={currentRule} onUpdate={handleUpdate} />
)}
</div>
</div>
)}
{/* Empty State */}
{!currentRule && !showAllRules && !isAuthenticating && (
<div className="text-center py-16">
<FiSearch className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Search for an email address
</h3>
<p className="text-gray-600 max-w-md mx-auto">
Enter an email address above to view and configure its out-of-office auto-replies and forwarding rules
</p>
</div>
)}
{/* Authenticating State */}
{isAuthenticating && (
<div className="text-center py-16">
<FiRefreshCw className="w-16 h-16 text-primary-600 mx-auto mb-4 animate-spin" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Authenticating from Roundcube...
</h3>
<p className="text-gray-600 max-w-md mx-auto">
Please wait while we verify your session
</p>
</div>
)}
</main>
{/* Toast Notifications */}
{toast && (
<div className="fixed bottom-4 right-4 z-50">
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
</div>
)}
</div>
);
}
export default App;

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import { FiMail, FiPlus, FiTrash2, FiCheck } from 'react-icons/fi';
const Forwarding = ({ rule, onUpdate }) => {
const [forwards, setForwards] = useState(rule?.forwards || []);
const [newEmail, setNewEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const validateEmail = (email) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
const handleAddEmail = () => {
const trimmedEmail = newEmail.trim();
if (!trimmedEmail) {
setEmailError('Email address is required');
return;
}
if (!validateEmail(trimmedEmail)) {
setEmailError('Please enter a valid email address');
return;
}
if (forwards.includes(trimmedEmail)) {
setEmailError('This email address is already in the list');
return;
}
setForwards([...forwards, trimmedEmail]);
setNewEmail('');
setEmailError('');
};
const handleRemoveEmail = (emailToRemove) => {
setForwards(forwards.filter(email => email !== emailToRemove));
};
const handleSave = async () => {
setIsLoading(true);
try {
await onUpdate({
forwards: forwards,
});
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmail();
}
};
return (
<div className="space-y-6">
{/* Add Email Form */}
<div>
<label htmlFor="forward-email" className="block text-sm font-semibold text-gray-700 mb-2">
Add Forward Address
</label>
<div className="flex gap-2">
<div className="flex-1">
<input
id="forward-email"
type="email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
setEmailError('');
}}
onKeyPress={handleKeyPress}
placeholder="email@example.com"
className={`input-field ${emailError ? 'border-red-500 focus:ring-red-500' : ''}`}
/>
{emailError && (
<p className="mt-1 text-sm text-red-600">{emailError}</p>
)}
</div>
<button
onClick={handleAddEmail}
className="btn-primary whitespace-nowrap"
>
<FiPlus className="w-4 h-4 mr-2 inline" />
Add Email
</button>
</div>
<p className="mt-2 text-xs text-gray-500">
All emails sent to this address will be automatically forwarded to the addresses below
</p>
</div>
{/* Forward List */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-semibold text-gray-700">
Forward Addresses ({forwards.length})
</label>
</div>
{forwards.length === 0 ? (
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
<FiMail className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No forward addresses configured</p>
<p className="text-sm text-gray-500 mt-1">Add an email address above to get started</p>
</div>
) : (
<div className="space-y-2">
{forwards.map((email, index) => (
<div
key={index}
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-primary-300 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 bg-primary-100 rounded-full">
<FiCheck className="w-4 h-4 text-primary-600" />
</div>
<div>
<p className="font-medium text-gray-900">{email}</p>
<p className="text-xs text-gray-500">Active forward</p>
</div>
</div>
<button
onClick={() => handleRemoveEmail(email)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Remove forward"
>
<FiTrash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{/* Save Button */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isLoading}
className="btn-primary"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
);
};
export default Forwarding;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { FiMail } from 'react-icons/fi';
const Header = () => {
return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-50 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 bg-primary-600 rounded-lg">
<FiMail className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Email Configuration</h1>
<p className="text-xs text-gray-500">Manage auto-replies and forwarding rules</p>
</div>
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { FiCalendar, FiFileText } from 'react-icons/fi';
const OutOfOffice = ({ rule, onUpdate }) => {
const [isActive, setIsActive] = useState(rule?.ooo_active || false);
const [message, setMessage] = useState(rule?.ooo_message || '');
const [contentType, setContentType] = useState(rule?.ooo_content_type || 'text');
const [isLoading, setIsLoading] = useState(false);
const handleSave = async () => {
setIsLoading(true);
try {
await onUpdate({
ooo_active: isActive,
ooo_message: message,
ooo_content_type: contentType,
});
} finally {
setIsLoading(false);
}
};
const handleToggle = () => {
setIsActive(!isActive);
};
return (
<div className="space-y-6">
{/* Toggle Active/Inactive */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-3">
<FiCalendar className="w-5 h-5 text-gray-600" />
<div>
<h3 className="font-semibold text-gray-900">Out of Office Status</h3>
<p className="text-sm text-gray-600">
{isActive ? 'Auto-reply is currently active' : 'Auto-reply is currently inactive'}
</p>
</div>
</div>
<button
onClick={handleToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isActive ? 'bg-primary-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isActive ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Content Type Selector */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Message Format
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setContentType('text')}
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
contentType === 'text'
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
<FiFileText className="inline w-4 h-4 mr-2" />
Plain Text
</button>
<button
type="button"
onClick={() => setContentType('html')}
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
contentType === 'html'
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
<span className="font-mono text-sm mr-2">&lt;/&gt;</span>
HTML
</button>
</div>
</div>
{/* Message Editor */}
<div>
<label htmlFor="ooo-message" className="block text-sm font-semibold text-gray-700 mb-2">
Auto-Reply Message
</label>
<textarea
id="ooo-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={8}
placeholder={
contentType === 'html'
? '<p>I am currently out of office until [date].</p>\n<p>Best regards,<br>Your Name</p>'
: 'I am currently out of office until [date].\n\nBest regards,\nYour Name'
}
className="input-field font-mono text-sm resize-none"
disabled={!isActive}
/>
<p className="mt-2 text-xs text-gray-500">
{contentType === 'html' ? 'You can use HTML tags for formatting' : 'Plain text message'}
</p>
</div>
{/* Message Preview */}
{isActive && message && (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Message Preview
</label>
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
{contentType === 'html' ? (
<div dangerouslySetInnerHTML={{ __html: message }} className="prose prose-sm max-w-none" />
) : (
<pre className="text-sm text-gray-800 whitespace-pre-wrap font-sans">{message}</pre>
)}
</div>
</div>
)}
{/* Save Button */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isLoading || (isActive && !message.trim())}
className="btn-primary"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
);
};
export default OutOfOffice;

View File

@@ -0,0 +1,40 @@
import React, { useEffect } from 'react';
import { FiCheckCircle, FiXCircle, FiAlertCircle, FiX } from 'react-icons/fi';
const Toast = ({ message, type = 'success', onClose, duration = 3000 }) => {
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [duration, onClose]);
const icons = {
success: <FiCheckCircle className="w-5 h-5 text-green-500" />,
error: <FiXCircle className="w-5 h-5 text-red-500" />,
warning: <FiAlertCircle className="w-5 h-5 text-yellow-500" />,
};
const bgColors = {
success: 'bg-green-50 border-green-200',
error: 'bg-red-50 border-red-200',
warning: 'bg-yellow-50 border-yellow-200',
};
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${bgColors[type]} shadow-lg animate-slide-in`}>
{icons[type]}
<p className="flex-1 text-sm font-medium text-gray-900">{message}</p>
<button
onClick={onClose}
className="p-1 hover:bg-white/50 rounded transition-colors"
>
<FiX className="w-4 h-4 text-gray-600" />
</button>
</div>
);
};
export default Toast;

56
frontend/src/index.css Normal file
View File

@@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 font-sans antialiased;
}
}
@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 active:bg-primary-800 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply px-4 py-2 bg-gray-200 text-gray-800 rounded-lg font-medium hover:bg-gray-300 active:bg-gray-400 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-danger {
@apply px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 active:bg-red-800 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
}
.input-field {
@apply w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-shadow duration-150 outline-none;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-100 p-6;
}
.tab-active {
@apply border-b-2 border-primary-600 text-primary-600 font-semibold;
}
.tab-inactive {
@apply border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300;
}
}
@layer utilities {
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,70 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// API Methods
export const emailRulesAPI = {
// Validate authentication token from Roundcube
validateToken: async (email, expires, signature) => {
const response = await api.post('/api/auth/validate-token', {
email,
expires,
signature,
});
return response.data;
},
// Get all rules
getAllRules: async () => {
const response = await api.get('/api/rules');
return response.data;
},
// Get rule for specific email
getRule: async (email) => {
const response = await api.get(`/api/rules/${encodeURIComponent(email)}`);
return response.data;
},
// Create or update rule
createOrUpdateRule: async (ruleData) => {
const response = await api.post('/api/rules', ruleData);
return response.data;
},
// Update existing rule
updateRule: async (email, ruleData) => {
const response = await api.put(`/api/rules/${encodeURIComponent(email)}`, ruleData);
return response.data;
},
// Delete rule
deleteRule: async (email) => {
const response = await api.delete(`/api/rules/${encodeURIComponent(email)}`);
return response.data;
},
};
// Error handler
api.interceptors.response.use(
(response) => response,
(error) => {
const errorMessage = error.response?.data?.message || error.message || 'An error occurred';
const statusCode = error.response?.status;
console.error('API Error:', errorMessage, 'Status:', statusCode);
// Create custom error with status code
const customError = new Error(errorMessage);
customError.statusCode = statusCode;
throw customError;
}
);
export default api;

View File

@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3008,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})