initial
This commit is contained in:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:3001
|
||||
21
frontend/.gitignore
vendored
Normal file
21
frontend/.gitignore
vendored
Normal 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
16
frontend/index.html
Normal 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
2870
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
387
frontend/src/App.jsx
Normal file
387
frontend/src/App.jsx
Normal 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;
|
||||
155
frontend/src/components/Forwarding.jsx
Normal file
155
frontend/src/components/Forwarding.jsx
Normal 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;
|
||||
24
frontend/src/components/Header.jsx
Normal file
24
frontend/src/components/Header.jsx
Normal 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;
|
||||
140
frontend/src/components/OutOfOffice.jsx
Normal file
140
frontend/src/components/OutOfOffice.jsx
Normal 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"></></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;
|
||||
40
frontend/src/components/Toast.jsx
Normal file
40
frontend/src/components/Toast.jsx
Normal 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
56
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
70
frontend/src/services/api.js
Normal file
70
frontend/src/services/api.js
Normal 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;
|
||||
29
frontend/tailwind.config.js
Normal file
29
frontend/tailwind.config.js
Normal 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
15
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user