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