initial
This commit is contained in:
commit
0347ee1342
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
.claude
|
||||||
|
dist
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'}`);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# API Configuration
|
||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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: [],
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
Loading…
Reference in New Issue