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

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
.claude
dist

236
README.md Normal file
View File

@ -0,0 +1,236 @@
# Email Configuration Manager
A modern, professional web application for managing email rules including out-of-office auto-replies and email forwarding. Built with React, Express, and AWS DynamoDB.
## Features
- **Out of Office Management**: Configure auto-reply messages with HTML or plain text formatting
- **Email Forwarding**: Set up multiple forward addresses for any email
- **Modern UI/UX**: Clean, professional interface built with React and Tailwind CSS
- **Real-time Updates**: Instant feedback with toast notifications
- **AWS DynamoDB**: Reliable cloud storage for email rules
## Tech Stack
### Frontend
- **React 18** - Modern UI library
- **Vite** - Fast build tool and dev server
- **Tailwind CSS** - Utility-first CSS framework
- **Axios** - HTTP client for API calls
- **React Icons** - Beautiful icon library
### Backend
- **Node.js & Express** - REST API server
- **AWS SDK v3** - DynamoDB client
- **Express Validator** - Request validation
- **Helmet** - Security headers
- **Morgan** - HTTP request logger
## Project Structure
```
config-email/
├── backend/ # Express API server
│ ├── server.js # Main server file
│ ├── package.json # Backend dependencies
│ └── .env # Environment variables
├── frontend/ # React application
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── services/ # API service layer
│ │ ├── App.jsx # Main app component
│ │ ├── main.jsx # Entry point
│ │ └── index.css # Global styles
│ ├── index.html # HTML template
│ ├── package.json # Frontend dependencies
│ └── vite.config.js # Vite configuration
└── README.md # This file
```
## Setup Instructions
### Prerequisites
- Node.js 18+ installed
- AWS Account with DynamoDB access
- AWS credentials (Access Key ID and Secret Access Key)
### 1. Install Dependencies
```bash
# Install backend dependencies
cd backend
npm install
# Install frontend dependencies
cd ../frontend
npm install
```
### 2. Configure Environment Variables
Backend environment variables are already configured in `backend/.env`:
- AWS credentials
- DynamoDB table name: `email-rules`
- Region: `us-east-2`
Frontend environment variables are set in `frontend/.env`:
- API URL: `http://localhost:3001`
### 3. Start the Application
**Terminal 1 - Start Backend API:**
```bash
cd backend
npm start
```
The API will start on `http://localhost:3001`
**Terminal 2 - Start Frontend:**
```bash
cd frontend
npm run dev
```
The UI will start on `http://localhost:3000`
### 4. Access the Application
Open your browser and navigate to:
```
http://localhost:3000
```
## Usage
### Managing Email Rules
1. **Search for an Email**: Enter an email address in the search box and click "Search"
2. **Configure Out of Office**:
- Toggle the OOO status on/off
- Choose between Plain Text or HTML format
- Enter your auto-reply message
- Preview the message before saving
3. **Set Up Forwarding**:
- Add email addresses to forward incoming messages
- Remove addresses as needed
4. **Save Changes**: Click "Save Changes" to update the rules
5. **View All Rules**: Click "All Rules" to see all configured email addresses
## API Endpoints
### GET /api/rules
Get all email rules
```bash
curl http://localhost:3001/api/rules
```
### GET /api/rules/:email
Get rule for specific email
```bash
curl http://localhost:3001/api/rules/user@example.com
```
### POST /api/rules
Create or update a rule
```bash
curl -X POST http://localhost:3001/api/rules \
-H "Content-Type: application/json" \
-d '{
"email_address": "user@example.com",
"ooo_active": true,
"ooo_message": "I am out of office",
"ooo_content_type": "text",
"forwards": ["forward@example.com"]
}'
```
### PUT /api/rules/:email
Update existing rule
```bash
curl -X PUT http://localhost:3001/api/rules/user@example.com \
-H "Content-Type: application/json" \
-d '{
"ooo_active": false,
"forwards": []
}'
```
### DELETE /api/rules/:email
Delete a rule
```bash
curl -X DELETE http://localhost:3001/api/rules/user@example.com
```
## DynamoDB Schema
The `email-rules` table uses the following structure:
```javascript
{
email_address: "user@example.com", // Partition Key (String)
ooo_active: true, // Boolean
ooo_message: "Out of office message", // String
ooo_content_type: "text", // String ("text" or "html")
forwards: ["email1@example.com"], // List of Strings
last_updated: "2025-12-26T12:00:00Z" // ISO 8601 timestamp
}
```
## Development
### Backend Development
```bash
cd backend
npm run dev # Uses nodemon for auto-reload
```
### Frontend Development
```bash
cd frontend
npm run dev # Vite hot-reload enabled
```
### Build for Production
**Frontend:**
```bash
cd frontend
npm run build
```
The production build will be in `frontend/dist/`
## Security Features
- **Helmet.js**: Security headers
- **CORS**: Configured for cross-origin requests
- **Input Validation**: All API endpoints validate input
- **AWS Credentials**: Never exposed to frontend
- **Environment Variables**: Sensitive data in .env files
## Troubleshooting
### Backend won't start
- Check if port 3001 is available
- Verify AWS credentials are correct
- Ensure DynamoDB table `email-rules` exists
### Frontend won't connect to API
- Verify backend is running on port 3001
- Check `VITE_API_URL` in `frontend/.env`
- Check browser console for CORS errors
### DynamoDB errors
- Verify AWS credentials have DynamoDB permissions
- Ensure table `email-rules` exists in `us-east-2` region
- Check AWS credentials are not expired
## License
MIT License
## Support
For issues and questions, please check the application logs or contact your system administrator.

11
backend/.env.example Normal file
View File

@ -0,0 +1,11 @@
# Server Configuration
PORT=3001
NODE_ENV=development
# AWS Configuration
AWS_REGION=us-east-2
AWS_ACCESS_KEY_ID=your_access_key_here
AWS_SECRET_ACCESS_KEY=your_secret_key_here
# DynamoDB Configuration
DYNAMODB_TABLE=email-rules

4
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
.env
*.log
.DS_Store

2728
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
backend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "email-config-api",
"version": "1.0.0",
"description": "REST API for Email Configuration Management",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["email", "api", "dynamodb", "aws"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"@aws-sdk/client-dynamodb": "^3.470.0",
"@aws-sdk/lib-dynamodb": "^3.470.0",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"morgan": "^1.10.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

345
backend/server.js Normal file
View File

@ -0,0 +1,345 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const crypto = require('crypto');
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, ScanCommand } = require('@aws-sdk/lib-dynamodb');
const { body, param, validationResult, query } = require('express-validator');
const { spawn } = require('child_process');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3001;
const TOKEN_SECRET = process.env.TOKEN_SECRET_KEY;
// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(morgan('dev'));
// AWS DynamoDB Configuration
const client = new DynamoDBClient({
region: process.env.AWS_REGION || 'us-east-2',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.DYNAMODB_TABLE || 'email-rules';
// Validation Middleware
const validateEmailRule = [
body('email_address').isEmail().withMessage('Valid email address is required'),
body('ooo_active').optional().isBoolean().withMessage('ooo_active must be a boolean'),
body('ooo_message').optional().isString().withMessage('ooo_message must be a string'),
body('ooo_content_type').optional().isIn(['text', 'html']).withMessage('ooo_content_type must be "text" or "html"'),
body('forwards').optional().isArray().withMessage('forwards must be an array'),
body('forwards.*').optional().isEmail().withMessage('All forward addresses must be valid emails'),
];
const validateEmail = [
param('email').isEmail().withMessage('Valid email address is required'),
];
// Error Handler Middleware
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array(),
});
}
next();
};
// Token Validation Function
const validateToken = (email, expires, signature) => {
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (now > parseInt(expires)) {
return { valid: false, error: 'Token expired' };
}
// Verify signature
const data = `${email}|${expires}`;
const expected = crypto
.createHmac('sha256', TOKEN_SECRET)
.update(data)
.digest('hex');
if (signature !== expected) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true, email };
};
// Trigger Email Rules Synchronization
const triggerSync = () => {
const syncScriptPath = path.join(__dirname, '../sync/sync.js');
console.log('🔄 Triggering email rules synchronization...');
const syncProcess = spawn('sudo', ['node', syncScriptPath], {
detached: true,
stdio: 'ignore'
});
syncProcess.unref(); // Allow the parent to exit independently
console.log('✅ Sync triggered (running in background)');
};
// POST /api/auth/validate-token - Validate authentication token from Roundcube
app.post('/api/auth/validate-token', [
body('email').isEmail().withMessage('Valid email is required'),
body('expires').isNumeric().withMessage('Expires must be a number'),
body('signature').notEmpty().withMessage('Signature is required'),
], handleValidationErrors, (req, res) => {
try {
const { email, expires, signature } = req.body;
const result = validateToken(email, expires, signature);
if (!result.valid) {
return res.status(401).json({
error: 'Invalid token',
message: result.error,
});
}
res.json({
success: true,
email: result.email,
});
} catch (error) {
console.error('Token validation error:', error);
res.status(500).json({
error: 'Token validation failed',
message: error.message,
});
}
});
// Health Check
app.get('/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
// GET /api/rules - Get all rules
app.get('/api/rules', async (req, res) => {
try {
const command = new ScanCommand({
TableName: TABLE_NAME,
});
const response = await docClient.send(command);
res.json({
rules: response.Items || [],
count: response.Count || 0,
});
} catch (error) {
console.error('Error fetching rules:', error);
res.status(500).json({
error: 'Failed to fetch rules',
message: error.message,
});
}
});
// GET /api/rules/:email - Get rule for specific email
app.get('/api/rules/:email', validateEmail, handleValidationErrors, async (req, res) => {
try {
const email = decodeURIComponent(req.params.email);
const command = new GetCommand({
TableName: TABLE_NAME,
Key: {
email_address: email,
},
});
const response = await docClient.send(command);
if (!response.Item) {
return res.status(404).json({
error: 'Rule not found',
message: `No rule exists for email: ${email}`,
});
}
res.json(response.Item);
} catch (error) {
console.error('Error fetching rule:', error);
res.status(500).json({
error: 'Failed to fetch rule',
message: error.message,
});
}
});
// POST /api/rules - Create or update rule
app.post('/api/rules', validateEmailRule, handleValidationErrors, async (req, res) => {
try {
const { email_address, ooo_active, ooo_message, ooo_content_type, forwards } = req.body;
const item = {
email_address,
ooo_active: ooo_active || false,
ooo_message: ooo_message || '',
ooo_content_type: ooo_content_type || 'text',
forwards: forwards || [],
last_updated: new Date().toISOString(),
};
const command = new PutCommand({
TableName: TABLE_NAME,
Item: item,
});
await docClient.send(command);
// Trigger immediate synchronization
triggerSync();
res.status(201).json({
message: 'Rule created/updated successfully',
rule: item,
});
} catch (error) {
console.error('Error creating/updating rule:', error);
res.status(500).json({
error: 'Failed to create/update rule',
message: error.message,
});
}
});
// PUT /api/rules/:email - Update existing rule
app.put('/api/rules/:email', validateEmail, validateEmailRule, handleValidationErrors, async (req, res) => {
try {
const email = decodeURIComponent(req.params.email);
const { ooo_active, ooo_message, ooo_content_type, forwards } = req.body;
// First check if rule exists
const getCommand = new GetCommand({
TableName: TABLE_NAME,
Key: { email_address: email },
});
const existingRule = await docClient.send(getCommand);
if (!existingRule.Item) {
return res.status(404).json({
error: 'Rule not found',
message: `No rule exists for email: ${email}`,
});
}
// Merge with existing data
const item = {
...existingRule.Item,
ooo_active: ooo_active !== undefined ? ooo_active : existingRule.Item.ooo_active,
ooo_message: ooo_message !== undefined ? ooo_message : existingRule.Item.ooo_message,
ooo_content_type: ooo_content_type !== undefined ? ooo_content_type : existingRule.Item.ooo_content_type,
forwards: forwards !== undefined ? forwards : existingRule.Item.forwards,
last_updated: new Date().toISOString(),
};
const putCommand = new PutCommand({
TableName: TABLE_NAME,
Item: item,
});
await docClient.send(putCommand);
// Trigger immediate synchronization
triggerSync();
res.json({
message: 'Rule updated successfully',
rule: item,
});
} catch (error) {
console.error('Error updating rule:', error);
res.status(500).json({
error: 'Failed to update rule',
message: error.message,
});
}
});
// DELETE /api/rules/:email - Delete rule
app.delete('/api/rules/:email', validateEmail, handleValidationErrors, async (req, res) => {
try {
const email = decodeURIComponent(req.params.email);
// First check if rule exists
const getCommand = new GetCommand({
TableName: TABLE_NAME,
Key: { email_address: email },
});
const existingRule = await docClient.send(getCommand);
if (!existingRule.Item) {
return res.status(404).json({
error: 'Rule not found',
message: `No rule exists for email: ${email}`,
});
}
const deleteCommand = new DeleteCommand({
TableName: TABLE_NAME,
Key: {
email_address: email,
},
});
await docClient.send(deleteCommand);
// Trigger immediate synchronization
triggerSync();
res.json({
message: 'Rule deleted successfully',
email_address: email,
});
} catch (error) {
console.error('Error deleting rule:', error);
res.status(500).json({
error: 'Failed to delete rule',
message: error.message,
});
}
});
// 404 Handler
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Cannot ${req.method} ${req.path}`,
});
});
// Global Error Handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal Server Error',
message: err.message,
});
});
// Start Server
app.listen(PORT, () => {
console.log(`🚀 Email Config API running on port ${PORT}`);
console.log(`📊 DynamoDB Table: ${TABLE_NAME}`);
console.log(`🌍 Region: ${process.env.AWS_REGION || 'us-east-2'}`);
});

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,
},
},
},
})

29
generate-link.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# Generate Config-App Link with signed token
EMAIL=$1
SECRET="SHARED_SECRET_KEY_987654321"
EXPIRES=$(($(date +%s) + 3600)) # 1 hour from now
if [ -z "$EMAIL" ]; then
echo "Usage: ./generate-link.sh email@example.com"
exit 1
fi
# Create signature
DATA="${EMAIL}|${EXPIRES}"
SIGNATURE=$(echo -n "$DATA" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
# URL encode email
EMAIL_ENCODED=$(printf %s "$EMAIL" | xxd -plain | tr -d '\n' | sed 's/\(..\)/%\1/g')
# Generate URL
URL="http://localhost:3009/?email=${EMAIL_ENCODED}&expires=${EXPIRES}&signature=${SIGNATURE}"
echo ""
echo "✅ Config-App Link generated:"
echo ""
echo "$URL"
echo ""
echo "📋 Copy this link and open in browser"
echo ""

71
start.sh Executable file
View File

@ -0,0 +1,71 @@
#!/bin/bash
echo "🚀 Starting Email Configuration Manager..."
echo ""
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Check for Node.js
if ! command_exists node; then
echo "❌ Error: Node.js is not installed"
echo "Please install Node.js 18+ from https://nodejs.org"
exit 1
fi
echo "✅ Node.js version: $(node --version)"
echo ""
# Check if dependencies are installed
if [ ! -d "backend/node_modules" ]; then
echo "📦 Installing backend dependencies..."
cd backend && npm install && cd ..
echo ""
fi
if [ ! -d "frontend/node_modules" ]; then
echo "📦 Installing frontend dependencies..."
cd frontend && npm install && cd ..
echo ""
fi
# Start backend in background
echo "🔧 Starting Backend API on port 3001..."
cd backend
npm start &
BACKEND_PID=$!
cd ..
# Wait a bit for backend to start
sleep 3
# Start frontend
echo "🎨 Starting Frontend on port 3008..."
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✨ Application is running!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "🌐 Frontend: http://localhost:3008"
echo "🔌 Backend: http://localhost:3001"
echo ""
echo "Press Ctrl+C to stop both servers"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
cd frontend
npm run dev -- --port 3008
# Cleanup function
cleanup() {
echo ""
echo "🛑 Shutting down servers..."
kill $BACKEND_PID 2>/dev/null
exit 0
}
trap cleanup INT TERM
wait

13
sync/.env Normal file
View File

@ -0,0 +1,13 @@
AWS_ACCESS_KEY_ID=AKIAU6GDWVAQXE5HQCWG
AWS_SECRET_ACCESS_KEY=S7cCm+zIPVXeOdKJmuvTKVh/Ul40jXKhHAv7OfIX
AWS_REGION=us-east-2
DYNAMODB_TABLE=email-rules
# Mail server paths (adjust based on your setup)
MAIL_DATA_PATH=/home/timo/docker-mailserver/docker-data/dms/mail-data
MAIL_STATE_PATH=/home/timo/docker-mailserver/docker-data/dms/mail-state
VIRTUAL_ALIASES_PATH=/home/timo/docker-mailserver/docker-data/dms/config/postfix-virtual.cf
SIEVE_BASE_PATH=/home/timo/docker-mailserver/docker-data/dms/mail-data
# Docker container name
MAILSERVER_CONTAINER=mailserver-new

171
sync/QUICKSTART.md Normal file
View File

@ -0,0 +1,171 @@
# Quick Start Guide - Email Rules Sync
## What This Does
✅ **Reads email rules from AWS DynamoDB**
✅ **Generates Sieve scripts for Out-of-Office auto-replies**
✅ **Generates Postfix virtual aliases for email forwarding**
✅ **Syncs every 5 minutes automatically**
## Setup (5 Minutes)
### Step 1: Setup Sudo Permissions
```bash
cd /home/timo/config-email/sync
./setup-sudo.sh
```
This allows the sync script to change file ownership without password prompts.
### Step 2: Test Manual Sync
```bash
sudo node sync.js
```
You should see:
```
✅ Found X email rules
✅ Created Sieve script for user@domain.com
✅ Updated virtual aliases
✅ Postfix reloaded
✅ Dovecot reloaded
```
### Step 3: Install Cron Job
```bash
./install-cron.sh
```
This sets up automatic syncing every 5 minutes.
## Verify It's Working
### Check Logs
```bash
tail -f /tmp/email-rules-sync.log
```
### Check Sieve Scripts (Auto-Reply)
```bash
# For support@qrmaster.net
cat /home/timo/docker-mailserver/docker-data/dms/mail-data/qrmaster.net/support/home/.dovecot.sieve
```
### Check Virtual Aliases (Forwarding)
```bash
cat /home/timo/docker-mailserver/docker-data/dms/config/postfix-virtual.cf
```
## Test Auto-Reply
1. Go to Roundcube → Settings → Email Configuration
2. Click "Open Email Configuration"
3. Enable "Out of Office"
4. Set message: "I'm out until Monday"
5. Click "Update Rule"
6. Wait 5 minutes (or run `sudo node sync.js` manually)
7. Send an email to that address
8. You should receive an auto-reply! 🎉
## Test Forwarding
1. Go to Roundcube → Settings → Email Configuration
2. Click "Open Email Configuration"
3. Go to "Email Forwarding" tab
4. Add forward address: `your@email.com`
5. Click "Update Rule"
6. Wait 5 minutes (or run `sudo node sync.js` manually)
7. Send an email to that address
8. You should receive it at your forward address! 🎉
## Architecture
```
┌──────────────┐
│ React UI │ ← User configures rules
└──────┬───────┘
┌──────────────┐
│ Express API │ ← Saves to DynamoDB
└──────┬───────┘
┌──────────────┐
│ DynamoDB │ ← Rules stored here
└──────┬───────┘
↓ (Every 5 min)
┌──────────────┐
│ Sync Script │ ← YOU ARE HERE
└──────┬───────┘
┌──────────────────────────────┐
│ Mail Server │
│ ┌────────┐ ┌────────────┐ │
│ │ Sieve │ │ Postfix │ │
│ │ OOO │ │ Forwarding │ │
│ └────────┘ └────────────┘ │
└──────────────────────────────┘
```
## Troubleshooting
### Sync fails with permission error
Run: `./setup-sudo.sh`
### Auto-reply not working
1. Check Sieve script was created:
```bash
sudo node sync.js
# Look for "✅ Created Sieve script for..."
```
2. Check Dovecot logs:
```bash
docker logs mailserver-new 2>&1 | grep -i sieve
```
### Forwarding not working
1. Check virtual aliases:
```bash
cat /home/timo/docker-mailserver/docker-data/dms/config/postfix-virtual.cf
```
2. Check Postfix logs:
```bash
docker logs mailserver-new 2>&1 | grep -i virtual
```
3. Manually reload:
```bash
docker exec mailserver-new postfix reload
```
## Next Steps
1. ✅ Setup sudo permissions
2. ✅ Test manual sync
3. ✅ Install cron job
4. ✅ Test auto-reply
5. ✅ Test forwarding
6. 🎉 Enjoy automated email rules!
## Files
- `sync.js` - Main sync script
- `setup-sudo.sh` - Setup sudo permissions
- `install-cron.sh` - Install cron job
- `.env` - Configuration (AWS credentials)
- `QUICKSTART.md` - This file
- `README.md` - Detailed documentation

231
sync/README.md Normal file
View File

@ -0,0 +1,231 @@
# Email Rules Sync System
This script synchronizes email rules from AWS DynamoDB to the mail server, enabling:
- **Out-of-Office Auto-Replies** (Sieve scripts)
- **Email Forwarding** (Postfix virtual aliases)
## How It Works
```
┌─────────────┐
│ DynamoDB │ ← Rules stored here (via Web UI)
│ email-rules │
└──────┬──────┘
│ Sync Script (every 5 minutes)
┌─────────────────────────────────────┐
│ Mail Server (Docker) │
│ ┌────────────┐ ┌───────────────┐ │
│ │ Sieve │ │ Postfix │ │
│ │ Scripts │ │Virtual Aliases│ │
│ │ (OOO) │ │ (Forwarding) │ │
│ └────────────┘ └───────────────┘ │
└─────────────────────────────────────┘
```
## Setup
### 1. Install Dependencies
```bash
cd /home/timo/config-email/sync
npm install
```
### 2. Configure Environment
The `.env` file is already configured with:
- AWS credentials
- DynamoDB table name
- Mail server paths
### 3. Test Manual Sync
```bash
npm start
```
You should see output like:
```
🚀 Starting email rules sync...
📊 DynamoDB Table: email-rules
🌍 Region: us-east-2
📥 Fetching rules from DynamoDB...
✅ Found 2 email rules
📝 Processing Sieve scripts (Out-of-Office)...
✅ Created Sieve script for support@qrmaster.net
✅ Processed 1 Sieve scripts
📮 Updating virtual aliases (Forwarding)...
✅ Found 0 forwarding rules
✅ Updated virtual aliases
🔄 Applying changes to mail server...
✅ Postfix reloaded
✅ Dovecot reloaded
✨ Sync completed successfully!
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total Rules: 2
OOO Active: 1
Forwarding Active: 0
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
### 4. Install as Cron Job (Automatic Sync)
```bash
./install-cron.sh
```
This will:
- Run sync every 5 minutes
- Log to `/tmp/email-rules-sync.log`
## Usage
### View Sync Logs
```bash
tail -f /tmp/email-rules-sync.log
```
### Manual Sync
```bash
cd /home/timo/config-email/sync
npm start
```
### Check Current Cron Jobs
```bash
crontab -l
```
### Remove Cron Job
```bash
crontab -l | grep -v "email-rules-sync" | crontab -
```
## What Gets Generated
### 1. Sieve Scripts (Out-of-Office)
Location: `/home/timo/docker-mailserver/docker-data/dms/mail-data/{domain}/{user}/home/.dovecot.sieve`
Example for `support@qrmaster.net`:
```sieve
require ["vacation", "variables"];
# Auto-Reply / Out-of-Office
# Generated by Email Rules Sync System
# Last updated: 2025-12-27T12:00:00.000Z
if true {
vacation
:days 1
:subject "Out of Office"
"I am currently out of office and will respond when I return.";
}
```
### 2. Virtual Aliases (Forwarding)
Location: `/home/timo/docker-mailserver/docker-data/dms/config/postfix-virtual.cf`
Example:
```
# Virtual Aliases - Email Forwarding
# Generated by Email Rules Sync System
# Last updated: 2025-12-27T12:00:00.000Z
# Forwarding for support@qrmaster.net
support@qrmaster.net forward1@example.com,forward2@example.com
```
## DynamoDB Schema
The sync script expects this schema:
```javascript
{
email_address: "support@qrmaster.net", // Primary Key
ooo_active: true, // Enable/disable auto-reply
ooo_message: "I am out of office...", // Auto-reply message
ooo_content_type: "text", // "text" or "html"
forwards: ["user@example.com"], // Array of forward addresses
last_updated: "2025-12-27T12:00:00.000Z" // Timestamp
}
```
## Troubleshooting
### Script fails to connect to DynamoDB
Check AWS credentials in `.env`:
```bash
cat .env | grep AWS
```
### Sieve scripts not working
1. Check script was created:
```bash
ls -la /home/timo/docker-mailserver/docker-data/dms/mail-data/qrmaster.net/support/home/
```
2. Check Dovecot logs:
```bash
docker logs mailserver-new 2>&1 | grep -i sieve
```
### Forwarding not working
1. Check virtual aliases file:
```bash
cat /home/timo/docker-mailserver/docker-data/dms/config/postfix-virtual.cf
```
2. Check Postfix logs:
```bash
docker logs mailserver-new 2>&1 | grep -i virtual
```
3. Reload Postfix manually:
```bash
docker exec mailserver-new postfix reload
```
## Architecture
```
Web UI (React)
Backend API (Express)
DynamoDB (email-rules)
Sync Script (Node.js) ← You are here
Mail Server (Dovecot + Postfix)
```
## Files
- `sync.js` - Main sync script
- `package.json` - Dependencies
- `.env` - Configuration
- `install-cron.sh` - Cron job installer
- `README.md` - This file
## Support
For issues, check:
1. Sync logs: `/tmp/email-rules-sync.log`
2. Mail server logs: `docker logs mailserver-new`
3. DynamoDB table: AWS Console → DynamoDB → email-rules

39
sync/install-cron.sh Executable file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# Install Email Rules Sync as a cron job
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYNC_SCRIPT="$SCRIPT_DIR/sync.js"
echo "📦 Installing Email Rules Sync Cron Job..."
echo ""
# Make sync script executable
chmod +x "$SYNC_SCRIPT"
# Create cron job to run every 30 minutes (as fallback - main sync is event-driven)
CRON_JOB="*/30 * * * * cd $SCRIPT_DIR && sudo /usr/bin/node sync.js >> /tmp/email-rules-sync.log 2>&1"
# Check if cron job already exists
if crontab -l 2>/dev/null | grep -q "email-rules-sync"; then
echo "⚠️ Cron job already exists. Updating..."
(crontab -l 2>/dev/null | grep -v "email-rules-sync"; echo "$CRON_JOB") | crontab -
else
echo " Adding new cron job..."
(crontab -l 2>/dev/null; echo "$CRON_JOB") | crontab -
fi
echo ""
echo "✅ Cron job installed successfully!"
echo ""
echo "The sync script will run every 30 minutes as a fallback."
echo "Main synchronization is event-driven (triggered by API changes)."
echo "Logs are written to: /tmp/email-rules-sync.log"
echo ""
echo "To view current cron jobs:"
echo " crontab -l"
echo ""
echo "To view logs:"
echo " tail -f /tmp/email-rules-sync.log"
echo ""
echo "To run sync manually:"
echo " cd $SCRIPT_DIR && npm start"

1420
sync/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
sync/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "email-rules-sync",
"version": "1.0.0",
"description": "Sync email rules from DynamoDB to mail server",
"main": "sync.js",
"type": "module",
"scripts": {
"start": "node sync.js",
"sync": "node sync.js"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.679.0",
"@aws-sdk/lib-dynamodb": "^3.679.0",
"dotenv": "^16.4.5"
}
}

46
sync/setup-sudo.sh Executable file
View File

@ -0,0 +1,46 @@
#!/bin/bash
# Setup sudo permissions for email sync script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYNC_SCRIPT="$SCRIPT_DIR/sync.js"
USERNAME=$(whoami)
echo "🔐 Setting up sudo permissions for email rules sync..."
echo ""
# Create sudoers file
SUDOERS_FILE="/etc/sudoers.d/email-rules-sync"
# Check if already configured
if [ -f "$SUDOERS_FILE" ]; then
echo "⚠️ Sudoers file already exists at $SUDOERS_FILE"
echo "Remove it first if you want to recreate it"
exit 1
fi
# Create temp file
TEMP_SUDOERS=$(mktemp)
cat > "$TEMP_SUDOERS" << EOF
# Allow $USERNAME to run email-rules-sync without password
# This is needed to change file ownership to mail server user (UID 5000)
$USERNAME ALL=(ALL) NOPASSWD: /usr/bin/node $SYNC_SCRIPT
EOF
# Validate sudoers syntax
if visudo -c -f "$TEMP_SUDOERS" 2>/dev/null; then
echo "✅ Sudoers file syntax is valid"
echo "Moving to $SUDOERS_FILE..."
sudo mv "$TEMP_SUDOERS" "$SUDOERS_FILE"
sudo chmod 0440 "$SUDOERS_FILE"
echo "✅ Sudo permissions configured successfully!"
echo ""
echo "You can now run:"
echo " sudo node $SYNC_SCRIPT"
echo ""
echo "Without entering a password."
else
echo "❌ Sudoers file syntax error!"
rm -f "$TEMP_SUDOERS"
exit 1
fi

23
sync/sync-wrapper.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Wrapper script to run sync with proper permissions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONTAINER="mailserver-new"
# Run sync script and capture output
echo "🚀 Running email rules sync..."
# Copy sync script to container
docker cp "$SCRIPT_DIR/sync.js" $CONTAINER:/tmp/sync.js
docker cp "$SCRIPT_DIR/.env" $CONTAINER:/tmp/.env
docker cp "$SCRIPT_DIR/package.json" $CONTAINER:/tmp/package.json
# Install dependencies in container if needed
docker exec $CONTAINER bash -c "cd /tmp && npm install --quiet 2>/dev/null || true"
# Run sync inside container with proper environment
docker exec -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_REGION \
$CONTAINER bash -c "cd /tmp && node sync.js"
echo ""
echo "✅ Sync completed"

260
sync/sync.js Executable file
View File

@ -0,0 +1,260 @@
#!/usr/bin/env node
import 'dotenv/config';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';
import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';
// AWS DynamoDB Configuration
const client = new DynamoDBClient({
region: process.env.AWS_REGION || 'us-east-2',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.DYNAMODB_TABLE || 'email-rules';
// Paths
const VIRTUAL_ALIASES_PATH = process.env.VIRTUAL_ALIASES_PATH;
const SIEVE_BASE_PATH = process.env.SIEVE_BASE_PATH;
const MAILSERVER_CONTAINER = process.env.MAILSERVER_CONTAINER || 'mailserver-new';
/**
* Generate Sieve script for Out-of-Office auto-reply
*/
function generateSieveScript(rule) {
const { ooo_message, ooo_content_type } = rule;
// Escape special characters in the message
const escapedMessage = ooo_message.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const script = `require ["vacation", "variables"];
# Auto-Reply / Out-of-Office
# Generated by Email Rules Sync System
# Last updated: ${new Date().toISOString()}
if true {
vacation
:days 1
:subject "Out of Office"
${ooo_content_type === 'html' ? ':mime' : ''}
"${escapedMessage}";
}
`;
return script;
}
/**
* Get Sieve script path for an email address
*/
function getSievePath(email) {
const [user, domain] = email.split('@');
return path.join(SIEVE_BASE_PATH, domain, user, 'home', '.dovecot.sieve');
}
/**
* Write or remove Sieve script based on OOO status
*/
async function manageSieveScript(rule) {
const { email_address, ooo_active } = rule;
const [user, domain] = email_address.split('@');
// Check if mailbox exists first
const mailboxPath = path.join(SIEVE_BASE_PATH, domain, user);
try {
await fs.access(mailboxPath);
} catch (error) {
// Mailbox doesn't exist - skip silently
if (ooo_active) {
console.log(`⚠️ Skipping ${email_address} - mailbox not found (user might not exist yet)`);
}
return false;
}
const sievePath = getSievePath(email_address);
const sieveDir = path.dirname(sievePath);
try {
if (ooo_active) {
// Create Sieve script
const script = generateSieveScript(rule);
// Ensure directory exists
await fs.mkdir(sieveDir, { recursive: true });
// Write Sieve script
await fs.writeFile(sievePath, script, 'utf8');
console.log(`✅ Created Sieve script for ${email_address}`);
// Set proper permissions and ownership (important for mail server)
await fs.chmod(sievePath, 0o644);
// Change ownership to mail server user (UID 5000)
try {
await fs.chown(sievePath, 5000, 5000);
} catch (error) {
console.warn(`⚠️ Could not change ownership for ${sievePath} - run with sudo`);
}
return true;
} else {
// Remove Sieve script if it exists
try {
await fs.unlink(sievePath);
console.log(`🗑️ Removed Sieve script for ${email_address}`);
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(`❌ Error removing Sieve for ${email_address}:`, error.message);
}
}
return false;
}
} catch (error) {
console.error(`❌ Error managing Sieve for ${email_address}:`, error.message);
return false;
}
}
/**
* Generate Postfix virtual aliases content
*/
function generateVirtualAliases(rules) {
const lines = [
'# Virtual Aliases - Email Forwarding',
'# Generated by Email Rules Sync System',
`# Last updated: ${new Date().toISOString()}`,
'',
];
for (const rule of rules) {
const { email_address, forwards } = rule;
if (forwards && forwards.length > 0) {
// Add comment
lines.push(`# Forwarding for ${email_address}`);
// Add forwarding rule
// Format: source_email destination1,destination2,destination3
const destinations = forwards.join(',');
lines.push(`${email_address} ${destinations}`);
lines.push('');
}
}
return lines.join('\n');
}
/**
* Write virtual aliases file
*/
async function updateVirtualAliases(rules) {
try {
const content = generateVirtualAliases(rules);
await fs.writeFile(VIRTUAL_ALIASES_PATH, content, 'utf8');
console.log(`✅ Updated virtual aliases at ${VIRTUAL_ALIASES_PATH}`);
// Set proper permissions
await fs.chmod(VIRTUAL_ALIASES_PATH, 0o644);
return true;
} catch (error) {
console.error(`❌ Error updating virtual aliases:`, error.message);
return false;
}
}
/**
* Reload mail server services
*/
function reloadMailServer() {
try {
console.log('🔄 Reloading mail server services...');
// Reload Postfix
execSync(`docker exec ${MAILSERVER_CONTAINER} postfix reload`, { stdio: 'inherit' });
console.log('✅ Postfix reloaded');
// Reload Dovecot (for Sieve changes)
execSync(`docker exec ${MAILSERVER_CONTAINER} doveadm reload`, { stdio: 'inherit' });
console.log('✅ Dovecot reloaded');
return true;
} catch (error) {
console.error('❌ Error reloading mail server:', error.message);
return false;
}
}
/**
* Main sync function
*/
async function syncEmailRules() {
console.log('🚀 Starting email rules sync...');
console.log(`📊 DynamoDB Table: ${TABLE_NAME}`);
console.log(`🌍 Region: ${process.env.AWS_REGION}`);
console.log('');
try {
// 1. Fetch all rules from DynamoDB
console.log('📥 Fetching rules from DynamoDB...');
const command = new ScanCommand({
TableName: TABLE_NAME,
});
const response = await docClient.send(command);
const rules = response.Items || [];
console.log(`✅ Found ${rules.length} email rules`);
console.log('');
if (rules.length === 0) {
console.log(' No rules to sync. Exiting.');
return;
}
// 2. Process Sieve scripts (Out-of-Office)
console.log('📝 Processing Sieve scripts (Out-of-Office)...');
let sieveCount = 0;
for (const rule of rules) {
const success = await manageSieveScript(rule);
if (success) sieveCount++;
}
console.log(`✅ Processed ${sieveCount} Sieve scripts`);
console.log('');
// 3. Update virtual aliases (Forwarding)
console.log('📮 Updating virtual aliases (Forwarding)...');
const forwardingRules = rules.filter(r => r.forwards && r.forwards.length > 0);
console.log(`✅ Found ${forwardingRules.length} forwarding rules`);
await updateVirtualAliases(rules);
console.log('');
// 4. Reload mail server
console.log('🔄 Applying changes to mail server...');
reloadMailServer();
console.log('');
// 5. Summary
console.log('✨ Sync completed successfully!');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`Total Rules: ${rules.length}`);
console.log(`OOO Active: ${sieveCount}`);
console.log(`Forwarding Active: ${forwardingRules.length}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
} catch (error) {
console.error('❌ Sync failed:', error.message);
console.error(error);
process.exit(1);
}
}
// Run sync
syncEmailRules();

53
sync/view-db.js Executable file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env node
import 'dotenv/config';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({
region: process.env.AWS_REGION || 'us-east-2',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.DYNAMODB_TABLE || 'email-rules';
async function viewDatabase() {
console.log('📊 DynamoDB Table:', TABLE_NAME);
console.log('🌍 Region:', process.env.AWS_REGION);
console.log('');
try {
const command = new ScanCommand({
TableName: TABLE_NAME,
});
const response = await docClient.send(command);
const rules = response.Items || [];
console.log(`✅ Found ${rules.length} email rules:\n`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
for (const rule of rules) {
console.log(`\n📧 Email: ${rule.email_address}`);
console.log(` OOO Active: ${rule.ooo_active ? '✅ YES' : '❌ NO'}`);
if (rule.ooo_active) {
console.log(` OOO Message: "${rule.ooo_message.substring(0, 50)}${rule.ooo_message.length > 50 ? '...' : ''}"`);
console.log(` Content Type: ${rule.ooo_content_type}`);
}
console.log(` Forwards: ${rule.forwards && rule.forwards.length > 0 ? rule.forwards.join(', ') : 'None'}`);
console.log(` Last Updated: ${rule.last_updated || 'N/A'}`);
console.log(' ─────────────────────────────────────────────────────────────────');
}
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
} catch (error) {
console.error('❌ Error fetching data:', error.message);
process.exit(1);
}
}
viewDatabase();