first commit

This commit is contained in:
2026-01-12 14:09:28 +01:00
commit 2f4d41092c
12 changed files with 2647 additions and 0 deletions

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
venv/
env/
ENV/
# Data directory (will be mounted as volume)
data/
# Git
.git/
.gitignore
# Documentation
README.md
# IDE
.vscode/
.idea/
# Other
*.log
.DS_Store

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
config.ini
*.log
data/
.DS_Store
BUI*
build.sh
docker-compose.dist*
GITEA.md

136
DOCKER.md Normal file
View File

@@ -0,0 +1,136 @@
# Docker Quick Start Guide
## Prerequisites
- Docker installed on your system
- Docker Compose installed (usually comes with Docker Desktop)
## Quick Start
### 1. Start the application
```bash
docker-compose up -d
```
This will:
- Build the Docker image (first time only)
- Start the container in detached mode
- Create the `data` directory for the database
- Expose port 5172
### 2. Access the application
Open your browser and navigate to:
- Local: `http://localhost:5172`
- Network: `http://YOUR_SERVER_IP:5172`
### 3. Login
Default credentials:
- Password: `admin123`
**Important**: Change this password immediately after first login!
### 4. Configure Mailcow
1. Click "Configuration"
2. Enter your Mailcow server (without https://)
3. Enter your Mailcow API key
4. Click "Save Mailcow Config"
5. Click "Change Password" and set a secure password
### 5. Sync aliases
Click "Sync Aliases" to import your existing aliases from Mailcow.
## Common Commands
### View logs
```bash
docker-compose logs -f
```
### Stop the application
```bash
docker-compose down
```
### Restart the application
```bash
docker-compose restart
```
### Update after code changes
```bash
docker-compose up -d --build
```
### Remove everything (including data)
```bash
docker-compose down -v
rm -rf data/
```
## Data Persistence
The `./data` directory is mounted as a volume, containing:
- `malias2.db` - Your aliases database and configuration
- `malias2.log` - Application logs
This directory persists across container restarts and updates.
## Troubleshooting
### Container won't start
Check logs:
```bash
docker-compose logs mailcow-alias-manager
```
### Port already in use
Edit `docker-compose.yml` and change the port mapping:
```yaml
ports:
- "8080:5172" # Change 8080 to any available port
```
### Can't connect to Mailcow server
Make sure your Mailcow server is accessible from the Docker container. If Mailcow is running on `localhost`, you may need to use `host.docker.internal` instead.
### Reset to defaults
```bash
docker-compose down
rm -rf data/
docker-compose up -d
```
## Updating
To update to a new version:
1. Pull the latest code:
```bash
git pull
```
2. Rebuild and restart:
```bash
docker-compose up -d --build
```
Your data in the `./data` directory will be preserved.
## Security Notes
- Change the default password immediately
- The application is designed for internal networks
- For external access, use a reverse proxy with HTTPS (nginx, Traefik, etc.)
- Consider using Docker secrets for sensitive data in production
## Network Access
To access from other devices on your network:
1. Find your server's IP address
2. Access via `http://YOUR_IP:5172`
3. Make sure your firewall allows connections on port 5172

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files
COPY app.py .
COPY malias_wrapper.py .
COPY malias.py .
COPY templates/ templates/
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 5172
# Set environment variables
ENV PYTHONUNBUFFERED=1
# Run the application
CMD ["python", "app.py"]

297
README.md Normal file
View File

@@ -0,0 +1,297 @@
# Mailcow Alias Manager Web Interface
A Python-based web application for managing mail aliases on a Mailcow instance. Built with Flask and a dark theme interface for internal network use.
## Features
- Dark-themed user interface
- Simple password authentication
- Manage Mailcow mail aliases through a web interface
- List all aliases on your Mailcow server (paginated view)
- Search for aliases in local database
- Create new permanent aliases
- Create time-limited aliases (1 year validity)
- Delete existing aliases
- Sync aliases from Mailcow server to local database
- View all mail domains
## Setup Instructions
### Option A: Docker (Recommended)
#### 1. Download the docker-compose.yml file
Download `docker-compose.yml` to your preferred directory.
#### 2. Start the application
```bash
docker compose up -d
```
The application will be available at: `http://localhost:5172`
#### 3. View logs (optional)
```bash
docker compose logs -f
```
#### 4. Stop the application (when needed)
```bash
docker compose down
```
### Option B: Manual Python Installation
#### 1. Create a virtual environment (recommended)
```bash
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
#### 2. Install dependencies
```bash
pip install -r requirements.txt
```
#### 3. Run the application
```bash
python app.py
```
The application will be available at: `http://localhost:5172`
## Configuration
### 1. Configure Mailcow Connection
You can configure your Mailcow server connection either:
**Option A: Using the web interface (Recommended)**
- Click the "Configuration" button in the web interface
- Enter your Mailcow server (e.g., `mail.example.com` without https://)
- Enter your Mailcow API key
- Click Save
**Option B: Using malias.py directly**
```bash
python3 malias.py -s your.mailcow.server YOUR_API_KEY
```
### 2. Initial Sync
After configuring the connection, sync your aliases from the Mailcow server:
- Click the "Sync Aliases" button in the web interface
- This will populate the local database with your existing aliases
### 3. First Login
- Navigate to the login page
- Enter the default password: `admin123`
- You'll be redirected to the main application
- **IMPORTANT**: Immediately go to Configuration and change your password!
### 4. Change Default Password
After first login:
1. Click the "Configuration" button
2. Scroll to "Change Password" section
3. Enter current password: `admin123`
4. Enter your new password (minimum 6 characters)
5. Confirm your new password
6. Click "Change Password"
The password is stored encrypted in the database using bcrypt hashing.
## Usage
### Available Functions
**List All Aliases**
- Shows all aliases in a paginated table (20 per page)
- Navigate through pages using Previous/Next buttons
**Sync Aliases**
- Synchronizes aliases from Mailcow server to local database
- Run this periodically to keep local database up to date
**Show Domains**
- Lists all mail domains configured on your Mailcow instance
**Create Alias**
- Opens a dialog to create a new permanent alias
- Enter the alias email (e.g., `alias@example.com`)
- Enter the destination email where mail should be forwarded
**Delete Alias**
- Opens a dialog to delete an existing alias
- Enter the alias email to remove
**Create Timed Alias**
- Creates a time-limited alias (valid for 1 year)
- Enter the destination email (where the mail goes)
- Enter the domain to create the alias on
- The system will generate a unique timed alias
**Search Aliases**
- Search for aliases in the local database
- Searches both alias addresses and destination addresses
- Results show matching aliases with their destinations
**Configuration**
- Configure your Mailcow server address and API key
- Server address should be without `https://` (e.g., `mail.example.com`)
- Change your application password for security
## Mailcow API Integration
This application integrates with your existing `malias.py` script, which provides:
- Connection to Mailcow API
- Local SQLite database for caching aliases
- CRUD operations for mail aliases
- Time-limited alias creation
- Search and sync functionality
The web interface provides a user-friendly way to access these functions without using the command line.
## Security Note
This application uses password authentication with bcrypt hashing and is designed for internal network use. Security features:
- Passwords are hashed using bcrypt before storage
- Session-based authentication
- Password change functionality
- Minimum password length enforcement (6 characters)
For production use on a public network, consider implementing:
- HTTPS/TLS encryption
- More robust authentication mechanisms (2FA, OAuth, etc.)
- Rate limiting for login attempts
- Session timeout configuration
- Account lockout after failed attempts
Do not expose this application to the public internet without additional security measures, particularly HTTPS.
## Docker Details
The application runs in Docker with persistent data storage.
### Data Persistence
Your data is stored in the `./data` directory on your host machine:
- `malias2.db` - Database containing aliases and configuration
- `malias2.log` - Application logs
This directory persists across container restarts and updates.
### Customization
You can customize the timezone by editing `docker-compose.yml`:
```yaml
environment:
- TZ=Europe/Oslo # Change to your timezone
```
You can change the port by editing `docker-compose.yml`:
```yaml
ports:
- "8080:5172" # Change 8080 to your preferred port
```
### Useful Docker Commands
**View logs:**
```bash
docker compose logs -f
```
**Restart:**
```bash
docker compose restart
```
**Stop:**
```bash
docker compose down
```
**Update to newer version:**
```bash
docker compose pull
docker compose up -d
```
### Environment Variables:
You can customize the timezone in `docker-compose.yml`:
```yaml
environment:
- TZ=Europe/Oslo # Change to your timezone
```
### Docker Commands:
**Build and start (development):**
```bash
docker-compose up -d
```
**View logs:**
```bash
docker-compose logs -f mailcow-alias-manager
```
**Restart:**
```bash
docker-compose restart
```
**Stop:**
```bash
docker-compose down
```
**Rebuild after code changes:**
```bash
docker-compose up -d --build
```
## Documentation
- **README.md** - Main documentation (this file)
- **DOCKER.md** - Docker usage guide for end users
## What You Need
For Docker deployment:
- `docker-compose.yml` - The only file you need to download
- Docker and Docker Compose installed on your system
The application will automatically:
- Pull the Docker image
- Create the `data/` directory for your database
- Start on port 5172
For manual Python installation:
- All application files from the repository
- Python 3.11 or higher
## Troubleshooting
**"Error: No mailcow server or API key registered"**
- Make sure you've configured the Mailcow server and API key in the Configuration page
- The API key must have permissions to manage aliases on your Mailcow instance
**Search returns no results**
- Make sure you've run "Sync Aliases" first to populate the local database
- The search only searches the local database, not the live Mailcow server
**Connection errors**
- Verify your Mailcow server is accessible from the machine running this app
- Check that the API key is valid and has the correct permissions
- Review logs in `data/malias2.log`

212
app.py Normal file
View File

@@ -0,0 +1,212 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
from functools import wraps
import malias_wrapper as malias_w
import os
app = Flask(__name__)
app.secret_key = os.urandom(24) # Secret key for session management
# Initialize database on startup
malias_w.init_database()
def login_required(f):
"""Decorator to require login for routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('logged_in'):
return jsonify({'status': 'error', 'message': 'Not authenticated', 'redirect': '/login'}), 401
return f(*args, **kwargs)
return decorated_function
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login page"""
if request.method == 'POST':
password = request.json.get('password', '')
if malias_w.verify_password(password):
session['logged_in'] = True
return jsonify({'status': 'success', 'message': 'Login successful'})
else:
return jsonify({'status': 'error', 'message': 'Invalid password'})
# Check if already logged in
if session.get('logged_in'):
return redirect(url_for('index'))
return render_template('login.html')
@app.route('/logout')
def logout():
"""Logout"""
session.pop('logged_in', None)
return redirect(url_for('login'))
@app.route('/')
def index():
"""Main page - requires login"""
if not session.get('logged_in'):
return redirect(url_for('login'))
return render_template('index.html')
@app.route('/list_aliases', methods=['POST'])
@login_required
def list_aliases():
"""List all aliases from Mailcow with pagination"""
try:
connection = malias_w.get_settings_from_db()
if not connection:
return jsonify({'status': 'error', 'message': 'Please configure Mailcow server first'})
# Get page number from request, default to 1
page = request.json.get('page', 1) if request.json else 1
result = malias_w.get_all_aliases(page=page, per_page=20)
return jsonify({'status': 'success', 'data': result})
except Exception as e:
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/sync_aliases', methods=['POST'])
@login_required
def sync_aliases():
"""Sync aliases from server to local DB"""
try:
count = malias_w.update_aliases()
result = f"Aliases synchronized successfully! Added {count} new aliases to local DB."
return jsonify({'status': 'success', 'message': result})
except Exception as e:
return jsonify({'status': 'error', 'message': f"Error syncing: {str(e)}"})
@app.route('/get_domains', methods=['POST'])
@login_required
def get_domains():
"""Get all mail domains"""
try:
domains = malias_w.get_domains()
domain_list = [d['domain_name'] for d in domains]
result = f"Domains: {', '.join(domain_list)}"
return jsonify({'status': 'success', 'message': result, 'domains': domain_list})
except Exception as e:
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/create_alias', methods=['POST'])
@login_required
def create_alias():
"""Create a new alias"""
try:
data = request.json
alias = data.get('alias', '')
goto = data.get('goto', '')
if not alias or not goto:
return jsonify({'status': 'error', 'message': 'Both alias and destination are required'})
malias_w.create_alias(alias, goto)
result = f"Alias {alias} created successfully for {goto}"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
return jsonify({'status': 'error', 'message': f"Error creating alias: {str(e)}"})
@app.route('/delete_alias', methods=['POST'])
@login_required
def delete_alias_route():
"""Delete an alias"""
try:
data = request.json
alias = data.get('alias', '')
if not alias:
return jsonify({'status': 'error', 'message': 'Alias is required'})
malias_w.delete_alias(alias)
result = f"Alias {alias} deleted successfully"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
return jsonify({'status': 'error', 'message': f"Error deleting alias: {str(e)}"})
@app.route('/create_timed_alias', methods=['POST'])
@login_required
def create_timed_alias():
"""Create a time-limited alias"""
try:
data = request.json
username = data.get('username', '')
domain = data.get('domain', '')
if not username or not domain:
return jsonify({'status': 'error', 'message': 'Both username and domain are required'})
malias_w.create_timed_alias(username, domain)
result = f"Timed alias created for {username} on domain {domain}"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/search', methods=['POST'])
@login_required
def search():
"""Search for aliases"""
query = request.json.get('query', '')
if not query:
return jsonify({'status': 'error', 'message': 'No search query provided'})
try:
results = malias_w.search_aliases(query)
if results:
result_list = [f"{alias}{goto}" for alias, goto in results]
message = f"Found {len(results)} alias(es):\n" + "\n".join(result_list)
else:
message = f"No aliases found matching '{query}'"
return jsonify({'status': 'success', 'message': message, 'query': query})
except Exception as e:
return jsonify({'status': 'error', 'message': f"Search error: {str(e)}"})
@app.route('/config', methods=['GET', 'POST'])
@login_required
def config_page():
"""Configuration management for Mailcow connection"""
if request.method == 'POST':
mailcow_server = request.json.get('mailcow_server', '')
mailcow_api_key = request.json.get('mailcow_api_key', '')
if mailcow_server and mailcow_api_key:
malias_w.set_connection_info(mailcow_server, mailcow_api_key)
return jsonify({'status': 'success', 'message': 'Mailcow configuration saved successfully'})
else:
return jsonify({'status': 'error', 'message': 'Server and API key are required'})
# GET request - return current config
try:
current_config = malias_w.get_config()
return jsonify(current_config)
except Exception as e:
return jsonify({'mailcow_server': '', 'mailcow_api_key': ''})
@app.route('/change_password', methods=['POST'])
@login_required
def change_password_route():
"""Change password"""
try:
data = request.json
old_password = data.get('old_password', '')
new_password = data.get('new_password', '')
confirm_password = data.get('confirm_password', '')
if not old_password or not new_password or not confirm_password:
return jsonify({'status': 'error', 'message': 'All fields are required'})
if new_password != confirm_password:
return jsonify({'status': 'error', 'message': 'New passwords do not match'})
if len(new_password) < 6:
return jsonify({'status': 'error', 'message': 'Password must be at least 6 characters'})
malias_w.change_password(old_password, new_password)
return jsonify({'status': 'success', 'message': 'Password changed successfully'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5172)

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
mailcow-alias-manager:
image: gitlab.pm/rune/malias-web:latest
container_name: mailcow-alias-manager
ports:
- "5172:5172"
volumes:
- ./data:/app/data
restart: unless-stopped
environment:
- TZ=Europe/Oslo

621
malias.py Normal file
View File

@@ -0,0 +1,621 @@
#!/usr/bin/python3 -W ignore::DeprecationWarning
import sys
import sqlite3
from pathlib import Path
from sqlite3 import Error
import urllib.request
import json
import logging
import argparse
import os
import time
from rich import print
from datetime import datetime, timedelta
from string import ascii_letters, digits
from argparse import RawTextHelpFormatter
from operator import itemgetter
import httpx
# Info pages for dev
# https://mailcow.docs.apiary.io/#reference/aliases/get-aliases/get-aliases
# https://demo.mailcow.email/api/#/Aliases
# HTTPx ref -> https://www.python-httpx.org/
homefilepath = Path.home()
filepath = homefilepath.joinpath('.config/malias2')
database = filepath.joinpath('malias2.db')
logfile = filepath.joinpath('malias2.log')
Path(filepath).mkdir(parents=True, exist_ok=True)
logging.basicConfig(filename=logfile,level=logging.INFO,format='%(message)s')
logging.getLogger("httpx").setLevel(logging.ERROR)
app_version = '2.0'
db_version = '2.0.0'
footer = 'malias version %s'%(app_version)
def unix_to_datetime(unix_timestamp):
return datetime.fromtimestamp(unix_timestamp)
def get_latest_release():
version = 'N/A'
req = httpx.get('https://gitlab.pm/api/v1/repos/rune/malias/releases/latest',
headers={"Content-Type": "application/json"}
)
version = req.json()['tag_name']
return version
def release_check():
latest_release = get_latest_release()
if app_version != latest_release:
print('[b]New version available @ [i]https://iurl.no/malias[/b][/i]')
else:
print ('You have the the latest version. Version: %s' %(app_version))
def connect_database():
Path(filepath).mkdir(parents=True, exist_ok=True)
# noinspection PyShadowingNames
conn = None
try:
conn = sqlite3.connect(database)
except Error as e:
logging.error(time.strftime("%Y-%m-%d %H:%M") + ' - Error : ' + str(e))
print(e)
finally:
if conn:
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS settings (
id INTEGER NOT NULL PRIMARY KEY,
first_run INTEGER,
server TEXT,
apikey TEXT NOT NULL,
data_copy INTEGER
)''')
c.execute('''CREATE TABLE IF NOT EXISTS aliases
(id integer NOT NULL PRIMARY KEY,
alias text NOT NULL,
goto text NOT NULL,
created text NOT NULL)''')
c.execute('''CREATE TABLE IF NOT EXISTS timedaliases
(id integer NOT NULL PRIMARY KEY,
alias text NOT NULL,
goto text NOT NULL,
validity text NOT NULL)''')
c.execute('''CREATE TABLE IF NOT EXISTS dbversion
(version integer NOT NULL DEFAULT 0)''')
c.execute('''CREATE TABLE IF NOT EXISTS timedaliases
(id integer NOT NULL PRIMARY KEY,
alias text NOT NULL,
goto text NOT NULL,
validity text NOT NULL)''')
conn.commit()
first_run(conn)
return conn
def first_run(conn):
now = datetime.now().strftime("%m-%d-%Y %H:%M")
cursor = conn.cursor()
cursor.execute('SELECT count(*) FROM settings')
count = cursor.fetchone()[0]
if count == 0:
logging.error(now + ' - First run!')
cursor.execute('INSERT INTO settings values(?,?,?,?,?)', (0, 1, 'dummy.server','DUMMY_KEY',0))
cursor.execute('INSERT INTO dbversion values(?)', (db_version,))
conn.commit()
return None
def get_settings(kind):
now = datetime.now().strftime("%m-%d-%Y %H:%M")
cursor = conn.cursor()
cursor.execute('SELECT * FROM settings')
data = cursor.fetchall()
first_run_status = data[0][1] # pyright: ignore
server = data[0][2] # pyright: ignore
api_key = data[0][3] # pyright: ignore
copy_status = data[0][4] # pyright: ignore
if kind == 'connection':
if server == 'dummy.server' or api_key =='DUMMY_KEY':
print('Error: No mailcow server or API key registered. Please add with [b]malias -s [i]your.server APIKEY[/i][/b]')
exit(0)
else:
connection = {'key': api_key, 'server':server}
req = httpx.get('https://'+connection['server']+'/api/v1/get/domain/0',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data=req.json()
code = str(req)
if code.find('200') != -1:
return connection # pyright: ignore
else:
logging.error(now + ' - Error : Server returned error: %s, ' %(data['msg']))
print('\n [b red]Error[/b red] : Server returned error [b]%s[/b]\n\n' %(data['msg']))
exit(0)
if kind == 'first_run_status':
return first_run_status
if kind == 'copy_status':
return copy_status
def get_last_timed(username):
connection = get_settings('connection')
req = httpx.get('https://'+connection['server']+'/api/v1/get/time_limited_aliases/%s' %username,
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
data.sort(key = itemgetter('validity'), reverse=True)
return(data[0])
def set_conection_info(server,apikey):
now = datetime.now().strftime("%m-%d-%Y %H:%M")
cursor = conn.cursor()
cursor.execute('UPDATE settings SET server = ?, apikey = ? WHERE id = 0',(server, apikey))
logging.info(now + ' - Info : Connectioninformations updated')
print('Your connection information has been updated.')
conn.commit()
def copy_data():
connection = get_settings('connection')
if get_settings('copy_status') == 0:
now = datetime.now().strftime("%m-%d-%Y %H:%M")
cursor = conn.cursor()
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
if not data:
print('\n [b red]Error[/b red] : No aliases on server %s. Nothing to copy!\n\n' %(connection['server']))
exit(0)
i=0
for data in data:
cursor.execute('INSERT INTO aliases values(?,?,?,?)', (data['id'], data['address'],data['goto'],now))
i=i+1
cursor.execute('UPDATE settings SET data_copy = ? WHERE id = 0',(1,))
conn.commit()
logging.info(now + ' - Info : Imported %s new aliases from %s ' %(str(i),connection['server']))
print('\n[b]Info[/b] : %s aliases imported from the mailcow instance %s to local DB\n' %(i, connection['server']))
else:
print('\n[b]Info[/b] : aliases alreday imported from the mailcow instance %s to local DB\n\n[i]Updating with any missing aliases![/i]' %(connection['server']))
update_data()
def update_data():
connection = get_settings('connection')
if get_settings('copy_status') == 1:
now = datetime.now().strftime("%m-%d-%Y %H:%M")
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
i = 0
count_alias = 0
cursor = conn.cursor()
for data in data:
cursor.execute('SELECT count(*) FROM aliases where alias like ? and goto like ?', (data['address'],data['goto'],))
count = cursor.fetchone()[0]
if count >= 1 :
i+=1
else:
cursor.execute('INSERT INTO aliases values(?,?,?,?)', (data['id'], data['address'],data['goto'],now))
count_alias+=1
i+=1
conn.commit()
if count_alias > 0:
logging.info(now + ' - Info : Local DB updated with %s new aliases from %s ' %(str(count_alias),connection['server']))
print('\n[b]Info[/b] : Local DB update with %s new aliases from %s \n' %(str(count_alias),connection['server']))
else:
print('\n[b]Info[/b] : No missing aliases from local DB \n')
def create(alias,to_address):
now = datetime.now().strftime("%m-%d-%Y %H:%M")
connection = get_settings('connection') # pyright: ignore
check = checklist(alias)
if check[0] == True:
logging.error(now + ' - Error : alias %s exists on the mailcow instance %s ' %(alias,connection['server']))
print('\n[b]Error[/b] : alias %s exists on the mailcow instance %s \n' %(alias,connection['server']))
exit(0)
elif check[1] == True:
logging.error(now + ' - Error : alias %s exists in local database.' %(alias))
print('\n[b]Error[/b] : alias %s exists in local database. \n' %(alias))
exit(0)
else:
try:
new_data = {'address': alias,'goto': to_address,'active': "1"}
new_data = json.dumps(new_data)
req = httpx.post('https://'+connection['server']+'/api/v1/add/alias',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
},
data=new_data
)
except httpx.HTTPError as exc:
print(f"Error while requesting {exc.request.url!r}.")
mail_id = alias_id(alias)
if mail_id == None:
logging.error(now + ' - Error : alias %s not created on the mailcow instance %s ' %(alias,connection['server']))
print('\n[b]Error[/b] : alias %s exists on the mailcow instance %s \n' %(alias,connection['server']))
else:
cursor = conn.cursor()
cursor.execute('INSERT INTO aliases values(?,?,?,?)', (mail_id, alias,to_address,now))
conn.commit()
logging.info(now + ' - Info : alias %s created for %s on the mailcow instance %s ' %(alias,to_address,connection['server']))
print('\n[b]Info[/b] : alias %s created for %s on the mailcow instance %s \n' %(alias,to_address,connection['server']))
def checklist(alias):
alias_e = None
alias_i = None
connection = get_settings('connection')
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
i = 0
for data in data:
if alias == data['address'] or alias in data['goto']:
alias_e = True
i=i+1
cursor = conn.cursor()
cursor.execute('SELECT count(*) FROM aliases where alias == ? or goto == ?', (alias,alias,))
count = cursor.fetchone()[0]
if count >= 1 :
alias_i = True
alias_exist = [alias_e,alias_i]
return alias_exist
def alias_id(alias):
connection = get_settings('connection')
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
i = 0
for data in data:
if data['address'] == alias:
return data['id']
i=i+1
return None
def number_of_aliases_in_db():
cursor = conn.cursor()
cursor.execute('SELECT count(*) FROM aliases')
count = cursor.fetchone()[0]
return count
def number_of_aliases_on_server():
connection = get_settings('connection')
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
return len(data)
def search(alias):
connection = get_settings('connection')
cursor = conn.cursor()
search_term = '%'+alias+'%'
cursor.execute('SELECT alias,goto from aliases where alias like ? or goto like ?',(search_term,search_term,))
localdata = cursor.fetchall()
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
remotedata = req.json()
remote = []
i=0
for data in remotedata:
if alias in remotedata[i]['address'] or alias in remotedata[i]['goto']:
remote.append((remotedata[i]['address'], remotedata[i]['goto']))
i=i+1
finallist = localdata + list(set(remote) - set(localdata))
i = 0
print('\nAliases on %s containg search term [b]%s[/b]' %(connection['server'],alias))
print('=================================================================')
for data in finallist:
the_alias = finallist[i][0].ljust(20,' ')
print(the_alias + '\tgoes to\t\t' + finallist[i][1])
i=i+1
print('\n'+footer+'\n')
def get_mail_domains(info):
connection = get_settings('connection')
cursor = conn.cursor()
req = httpx.get('https://'+connection['server']+'/api/v1/get/domain/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
remoteData = req.json()
if info:
total_aliases = 0
i=0
print('\n[b]malias[/b] - All email domains on %s' %(connection['server']))
print('==================================================================')
for domains in remoteData:
cursor.execute('SELECT count(*) FROM aliases where alias like ? or goto like ?', ('%'+remoteData[i]['domain_name']+'%','%'+remoteData[i]['domain_name']+'%',))
count = cursor.fetchone()[0]
total_aliases += count
print('%s \t\twith %s aliases' %(remoteData[i]['domain_name'],count))
i+=1
print('\n\nThere is a total of %s domains with %s aliases.\n%s' %(str(i),str(total_aliases),footer))
else:
return(remoteData)
def show_current_info():
connection = get_settings('connection')
latest_release = get_latest_release()
aliases_server = number_of_aliases_on_server()
alias_db = number_of_aliases_in_db()
mail_domains = get_mail_domains(False)
domain = ""
i=0
for domains in mail_domains:
if i!=0:
domain = domain + ', ' + str(mail_domains[i]['domain_name'])
else:
domain = domain + str(mail_domains[i]['domain_name'])
i+=1
print('\n[b]malias[/b] - Manage aliases on mailcow Instance.')
print('===================================================')
print('API key : [b]%s[/b]' % (connection['key']))
print('Mailcow Instance : [b]%s[/b]' % (connection['server']))
print('Active domains : [b]%s[/b]' % (domain))
print('Logfile : [b]%s[/b]' % (logfile))
print('Databse : [b]%s[b]' % (database))
print('Aliases on server : [b]%s[/b]' % (aliases_server))
print('Aliases in DB : [b]%s[/b]' % (alias_db))
print('')
if float(app_version) < float(latest_release):
print('App version : [b]%s[/b] a new version (%s) is available @ https://iurl.no/malias' % (app_version,latest_release))
else:
print('App version : [b]%s[/b]' % (app_version))
print('')
def delete_alias(alias):
status_e = None
status_i = None
now = datetime.now().strftime("%m-%d-%Y %H:%M")
connection = get_settings('connection')
check = checklist(alias)
if check[0] == None and check[1] == None:
print('\n[b]Error[/b] : The alias %s not found')
exit(0)
if check[0] or check[1] == True:
alias_server_id = alias_id(alias)
data = {'id': alias_server_id}
delete_data = json.dumps(data)
if check[0] == True and alias_server_id != None:
req = httpx.post('https://'+connection['server']+'/api/v1/delete/alias',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
},
data=delete_data
)
data=req.json()
code = str(req)
if code.find('200') != -1:
status_e = True
else:
status_e = None
if check[1] == True:
cursor = conn.cursor()
cursor.execute('DELETE from aliases where id = ?',(alias_server_id,))
conn.commit()
status_i = True
if status_e == True and status_i == True:
logging.info(now + ' - Info : alias %s deleted from the mailcow instance %s and Local DB' %(alias,connection['server']))
print('\n[b]Info[/b] : alias %s deleted from the mailcow instance %s and local DB' %(alias,connection['server']))
if status_e == True and status_i == None:
logging.info(now + ' - Info : alias %s deleted from the mailcow instance %s.' %(alias,connection['server']))
print('\n[b]Info[/b] : alias %s deleted from the mailcow instance %s.' %(alias,connection['server']))
if status_e == None and status_i == True:
logging.info(now + ' - Info : alias %s deleted from the local database.' %(alias))
print('\n[b]Info[/b] : alias %s deleted from the local database.' %(alias))
def create_timed(username,domain):
now = datetime.now().strftime("%m-%d-%Y %H:%M")
connection = get_settings('connection')
data = {'username': username,'domain': domain,'description': 'malias v'+app_version}
data_json = json.dumps(data)
req = httpx.post('https://'+connection['server']+'/api/v1/add/time_limited_alias',data=data_json,
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
response = json.loads(req.text)
if response[0]['type'] == 'danger' and response[0]['msg'] == 'domain_invalid':
logging.error(now + ' - Error : the domain %s does not exist.' %(domain))
print('[b][red]Error[/red][/b] : the domain %s does not exist.' %(domain))
exit(0)
if response[0]['type'] == 'danger' and response[0]['msg'] == 'access_denied':
logging.error(now + ' - Error : something went wrong. The server responded with access denied.')
print('[b][red]Error[/red][/b] : something went wrong. The server responded with [b]access denied[/b].')
exit(0)
alias = get_last_timed(username)
validity = unix_to_datetime(alias['validity'])
print('The timed alias %s was created. The alias is valid until %s UTC\n' %(alias['address'], validity))
cursor = conn.cursor()
cursor.execute('INSERT INTO timedaliases values(?,?,?,?)', (alias['validity'],alias['address'],username,validity))
conn.commit()
logging.info(now + ' - Info : timed alias %s created for %s and valid too %s UTC on the mailcow instance %s ' %(alias['address'],username,validity,connection['server']))
def check_local_db(alias_id):
cursor = conn.cursor()
cursor.execute('SELECT count(*) FROM aliases where id = ?',(alias_id,))
count = cursor.fetchone()[0]
return count
def list_alias():
now = datetime.now().strftime("%m-%d-%Y %H:%M")
connection = get_settings('connection')
cursor = conn.cursor()
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
i = 0
l = 0
print('\n[b]malias[/b] - All aliases on %s ([b]*[/b] also in local db)' %(connection['server']))
print('==================================================================')
for search in data:
the_alias = data[i]['address'].ljust(20,' ')
the_goto = data[i]['goto'].ljust(20,' ')
cursor.execute('SELECT count(*) FROM aliases where alias like ? or goto like ?', (data[i]['address'],data[i]['address'],))
count = cursor.fetchone()[0]
if count >= 1:
print(the_alias + '\tgoes to\t\t' + the_goto + '\t[b]*[/b]')
l=l+1
else:
print(the_alias + '\tgoes to\t\t' + the_goto)
i=i+1
print('\n\nTotal number of aliases %s on instance [b]%s[/b] and %s on [b]local DB[/b].' %(str(i),connection['server'],str(l)))
print('\n'+footer)
def export_data():
connection = get_settings('connection')
cursor = conn.cursor()
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
with open("alias.json", "w") as outfile:
json.dump(data, outfile, ensure_ascii=False, indent=4)
def list_timed_aliases(account):
connection = get_settings('connection')
req = httpx.get('https://'+connection['server']+'/api/v1/get/time_limited_aliases/'+account,
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
i = 0
print('\n[b]malias[/b] - Timed aliases on %s for %s' %(connection['server'], account))
print('==========================================================================================================')
for data in data:
the_alias = data['address'].ljust(30,' ')
the_goto = data['goto'].ljust(20,' ')
the_validity = unix_to_datetime(data['validity'])
print(the_alias + '\tgoes to\t\t' + the_goto+'Valid to: '+str(the_validity))
i=i+1
#print('\n\nTotal number of timed aliases on instance [b]%s[/b] for account.' %(connection['server'],account))
print('\n'+footer)
# For Testing purposes
def list_all():
connection = get_settings('connection')
req = httpx.get('https://'+connection['server']+'/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
print(data)
def updatedb():
# Function for updatimg DB when we have to
# 26.02.2025
# Placeholder for future updates and functions.
exit(1)
conn = connect_database()
parser = argparse.ArgumentParser(prog='malias',
description='Malias is an application for adding, creating, and deleting aliases on a Mailcow instance. \n\nUse the issues section in the git repo for any problems or suggestions. https://gitlab.pm/rune/malias',
formatter_class=RawTextHelpFormatter,
epilog='Making mailcow easier...')
parser.add_argument('-c', '--copy', help='Copy alias data from mailcow server to local DB.\n\n',
required=False, action='store_true')
parser.add_argument('-s', '--set', help='Set connection information.\n\n',
nargs=2, metavar=('server', 'APIKey'), required=False, action="append")
parser.add_argument('-a', '--add', help='Add new alias.\n\n',
nargs=2, metavar=('alias@domain.com', 'to@domain.com'), required=False, action="append")
parser.add_argument('-f', '--find', help='Search for alias.\n\n',
nargs=1, metavar=('alias@domain.com'), required=False, action="append")
parser.add_argument('-d', '--delete', help='Delete alias.\n\n',
nargs=1, metavar=('alias@domain.com'), required=False, action="append")
parser.add_argument('-t', '--timed', help='Add new time limited alias for user on domain. \nThe user@domain.com is where you want the alias to be delivered to.\nThe domain.com is which domain to use when creating the timed-alias.\nOne year validity\n\n',
nargs=2, metavar=('user@domain.com', 'domain.com'), required=False, action="append")
parser.add_argument('-w', '--alias', help='List timed (temprary) aliases connected toone account.\n\n',
nargs=1, metavar=('alias@domain.com'), required=False, action="append")
parser.add_argument('-l', '--list', help='List all aliases on the Mailcow instance.\n\n',
required=False, action='store_true')
parser.add_argument('-o', '--domains', help='List all mail domains on the Mailcow instance.\n\n',
required=False, action='store_true')
parser.add_argument('-e', '--export', help='List all mail domains on the Mailcow instance.\n\n',
required=False, action='store_true')
parser.add_argument('-v', '--version', help='Show current version and information\n\n',
required=False, action='store_true')
args = vars(parser.parse_args())
if args['copy']:
copy_data()
elif args['set']:
set_conection_info(args['set'][0][0],args['set'][0][1])
elif args['add']:
create(args['add'][0][0],args['add'][0][1])
elif args['find']:
search(args['find'][0][0])
elif args['version']:
show_current_info()
elif args['delete']:
delete_alias(args['delete'][0][0])
elif args['timed']:
create_timed(args['timed'][0][0],args['timed'][0][1])
elif args['alias']:
list_timed_aliases(args['alias'][0][0])
elif args['list']:
list_alias()
elif args['domains']:
get_mail_domains(True)
elif args['export']:
export_data()
else:
print('\n\nEh, sorry! I need something more to help you! If you write [b]malias -h[/b] I\'ll show a help screen to get you going!!!\n\n\n')

386
malias_wrapper.py Normal file
View File

@@ -0,0 +1,386 @@
"""
Wrapper functions for malias to handle SQLite threading issues in Flask
"""
import sqlite3
from pathlib import Path
import httpx
import json
from datetime import datetime
import os
import bcrypt
# Use project subfolder for database
project_dir = Path(__file__).parent
filepath = project_dir.joinpath('data')
database = filepath.joinpath('malias2.db')
logfile = filepath.joinpath('malias2.log')
def init_database():
"""Initialize database schema if it doesn't exist"""
Path(filepath).mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(database)
cursor = conn.cursor()
# Create tables
cursor.execute('''CREATE TABLE IF NOT EXISTS settings (
id INTEGER NOT NULL PRIMARY KEY,
first_run INTEGER,
server TEXT,
apikey TEXT NOT NULL,
data_copy INTEGER
)''')
cursor.execute('''CREATE TABLE IF NOT EXISTS aliases
(id integer NOT NULL PRIMARY KEY,
alias text NOT NULL,
goto text NOT NULL,
created text NOT NULL)''')
cursor.execute('''CREATE TABLE IF NOT EXISTS timedaliases
(id integer NOT NULL PRIMARY KEY,
alias text NOT NULL,
goto text NOT NULL,
validity text NOT NULL)''')
cursor.execute('''CREATE TABLE IF NOT EXISTS dbversion
(version integer NOT NULL DEFAULT 0)''')
cursor.execute('''CREATE TABLE IF NOT EXISTS auth
(id integer NOT NULL PRIMARY KEY,
password_hash text NOT NULL)''')
# Check if settings exist, if not create default
cursor.execute('SELECT count(*) FROM settings')
count = cursor.fetchone()[0]
if count == 0:
cursor.execute('INSERT INTO settings VALUES (?, ?, ?, ?, ?)',
(0, 1, 'dummy.server', 'DUMMY_KEY', 0))
# Check if auth table has password, if not create default
cursor.execute('SELECT count(*) FROM auth')
count = cursor.fetchone()[0]
if count == 0:
# Default password is "admin123"
default_hash = bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt())
cursor.execute('INSERT INTO auth VALUES (?, ?)', (0, default_hash.decode('utf-8')))
conn.commit()
conn.close()
def get_db_connection():
"""Get a fresh database connection for the current thread"""
Path(filepath).mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(database)
return conn
def get_settings_from_db():
"""Get Mailcow server and API key from database"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT server, apikey FROM settings WHERE id = 0')
data = cursor.fetchone()
conn.close()
if data and data[0] != 'dummy.server' and data[1] != 'DUMMY_KEY':
return {'server': data[0], 'key': data[1]}
return None
def set_connection_info(server, apikey):
"""Set Mailcow connection information"""
now = datetime.now().strftime("%m-%d-%Y %H:%M")
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('UPDATE settings SET server = ?, apikey = ? WHERE id = 0', (server, apikey))
conn.commit()
conn.close()
return True
def get_config():
"""Get current configuration"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT server, apikey FROM settings WHERE id = 0')
data = cursor.fetchone()
conn.close()
if data:
return {
'mailcow_server': data[0] if data[0] != 'dummy.server' else '',
'mailcow_api_key': data[1] if data[1] != 'DUMMY_KEY' else ''
}
return {'mailcow_server': '', 'mailcow_api_key': ''}
def search_aliases(query):
"""Search for aliases in local database"""
conn = get_db_connection()
cursor = conn.cursor()
search_term = '%' + query + '%'
cursor.execute('SELECT alias, goto FROM aliases WHERE alias LIKE ? OR goto LIKE ?',
(search_term, search_term))
results = cursor.fetchall()
conn.close()
return results
def get_number_of_aliases_on_server():
"""Get number of aliases from Mailcow server"""
connection = get_settings_from_db()
if not connection:
raise Exception("No Mailcow server configured")
req = httpx.get(f'https://{connection["server"]}/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
return len(data)
def get_all_aliases(page=1, per_page=20):
"""Get all aliases with pagination from Mailcow server"""
connection = get_settings_from_db()
if not connection:
raise Exception("No Mailcow server configured")
req = httpx.get(f'https://{connection["server"]}/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
# Calculate pagination
total_aliases = len(data)
total_pages = (total_aliases + per_page - 1) // per_page # Ceiling division
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
# Get the page slice
page_data = data[start_idx:end_idx]
# Format aliases
aliases = []
for alias_data in page_data:
aliases.append({
'alias': alias_data['address'],
'goto': alias_data['goto'],
'active': alias_data.get('active', '1')
})
return {
'aliases': aliases,
'page': page,
'per_page': per_page,
'total': total_aliases,
'total_pages': total_pages,
'has_prev': page > 1,
'has_next': page < total_pages
}
def get_domains():
"""Get all mail domains from Mailcow"""
connection = get_settings_from_db()
if not connection:
raise Exception("No Mailcow server configured")
req = httpx.get(f'https://{connection["server"]}/api/v1/get/domain/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
return req.json()
def update_aliases():
"""Sync aliases from server to local DB"""
connection = get_settings_from_db()
if not connection:
raise Exception("No Mailcow server configured")
now = datetime.now().strftime("%m-%d-%Y %H:%M")
req = httpx.get(f'https://{connection["server"]}/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
conn = get_db_connection()
cursor = conn.cursor()
count_alias = 0
for alias_data in data:
cursor.execute('SELECT count(*) FROM aliases WHERE alias LIKE ? AND goto LIKE ?',
(alias_data['address'], alias_data['goto']))
count = cursor.fetchone()[0]
if count == 0:
cursor.execute('INSERT INTO aliases VALUES (?, ?, ?, ?)',
(alias_data['id'], alias_data['address'], alias_data['goto'], now))
count_alias += 1
conn.commit()
conn.close()
return count_alias
def create_alias(alias, goto):
"""Create a new alias"""
connection = get_settings_from_db()
if not connection:
raise Exception("No Mailcow server configured")
now = datetime.now().strftime("%m-%d-%Y %H:%M")
# Check if alias exists
if check_alias_exists(alias):
raise Exception(f"Alias {alias} already exists")
# Create on server
new_data = {'address': alias, 'goto': goto, 'active': "1"}
new_data_json = json.dumps(new_data)
req = httpx.post(f'https://{connection["server"]}/api/v1/add/alias',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
},
data=new_data_json
)
# Get the new alias ID
alias_id_val = get_alias_id(alias)
if alias_id_val:
# Add to local DB
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('INSERT INTO aliases VALUES (?, ?, ?, ?)',
(alias_id_val, alias, goto, now))
conn.commit()
conn.close()
return True
else:
raise Exception("Failed to create alias on server")
def delete_alias(alias):
"""Delete an alias"""
connection = get_settings_from_db()
if not connection:
raise Exception("No Mailcow server configured")
alias_id_val = get_alias_id(alias)
if not alias_id_val:
raise Exception(f"Alias {alias} not found")
# Delete from server
delete_data = json.dumps({'id': alias_id_val})
req = httpx.post(f'https://{connection["server"]}/api/v1/delete/alias',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
},
data=delete_data
)
# Delete from local DB
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('DELETE FROM aliases WHERE id = ?', (alias_id_val,))
conn.commit()
conn.close()
return True
def create_timed_alias(username, domain):
"""Create a time-limited alias"""
connection = get_settings_from_db()
if not connection:
raise Exception("No Mailcow server configured")
data = {'username': username, 'domain': domain, 'description': 'malias web interface'}
data_json = json.dumps(data)
req = httpx.post(f'https://{connection["server"]}/api/v1/add/time_limited_alias',
data=data_json,
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
response = json.loads(req.text)
if response and len(response) > 0:
if response[0].get('type') == 'danger':
raise Exception(response[0].get('msg', 'Unknown error'))
return True
def check_alias_exists(alias):
"""Check if alias exists on server or in local DB"""
connection = get_settings_from_db()
if not connection:
return False
# Check server
req = httpx.get(f'https://{connection["server"]}/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
for item in data:
if alias == item['address'] or alias in item['goto']:
return True
# Check local DB
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT count(*) FROM aliases WHERE alias = ? OR goto = ?', (alias, alias))
count = cursor.fetchone()[0]
conn.close()
return count >= 1
def get_alias_id(alias):
"""Get alias ID from server"""
connection = get_settings_from_db()
if not connection:
return None
req = httpx.get(f'https://{connection["server"]}/api/v1/get/alias/all',
headers={"Content-Type": "application/json",
'X-API-Key': connection['key']
}
)
data = req.json()
for item in data:
if item['address'] == alias:
return item['id']
return None
# Password management functions
def verify_password(password):
"""Verify password against stored hash"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT password_hash FROM auth WHERE id = 0')
result = cursor.fetchone()
conn.close()
if not result:
return False
stored_hash = result[0].encode('utf-8')
return bcrypt.checkpw(password.encode('utf-8'), stored_hash)
def change_password(old_password, new_password):
"""Change password - requires old password for verification"""
# Verify old password
if not verify_password(old_password):
raise Exception("Current password is incorrect")
# Hash new password
new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt())
# Update database
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('UPDATE auth SET password_hash = ? WHERE id = 0', (new_hash.decode('utf-8'),))
conn.commit()
conn.close()
return True

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
Flask==3.0.0
python-dotenv==1.0.0
httpx==0.27.0
rich==13.7.0
bcrypt==4.1.2

750
templates/index.html Normal file
View File

@@ -0,0 +1,750 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Internal Web App</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #1a1a1a;
color: #e0e0e0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #ffffff;
margin-bottom: 30px;
text-align: center;
}
.action-buttons {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.btn {
background-color: #2d2d2d;
color: #ffffff;
border: 1px solid #404040;
padding: 12px 24px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
.btn:hover {
background-color: #3d3d3d;
border-color: #505050;
}
.btn:active {
background-color: #252525;
}
.btn-primary {
background-color: #0066cc;
border-color: #0066cc;
}
.btn-primary:hover {
background-color: #0052a3;
}
.btn-config {
background-color: #cc6600;
border-color: #cc6600;
margin-left: auto;
}
.btn-config:hover {
background-color: #a35200;
}
.btn-logout {
background-color: #8b0000;
border-color: #8b0000;
}
.btn-logout:hover {
background-color: #6b0000;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h1 {
margin: 0;
}
.search-section {
background-color: #2d2d2d;
padding: 25px;
border-radius: 8px;
margin-bottom: 30px;
border: 1px solid #404040;
}
.search-container {
display: flex;
gap: 10px;
}
.search-input {
flex: 1;
padding: 12px;
background-color: #1a1a1a;
border: 1px solid #404040;
border-radius: 5px;
color: #ffffff;
font-size: 16px;
}
.search-input:focus {
outline: none;
border-color: #0066cc;
}
.result-section {
background-color: #2d2d2d;
padding: 25px;
border-radius: 8px;
border: 1px solid #404040;
min-height: 100px;
}
.result-section h2 {
color: #ffffff;
margin-bottom: 15px;
font-size: 20px;
}
.result-content {
color: #b0b0b0;
line-height: 1.6;
}
.success {
color: #4CAF50;
}
.error {
color: #f44336;
}
.alias-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.alias-table th {
background-color: #1a1a1a;
color: #ffffff;
padding: 12px;
text-align: left;
border-bottom: 2px solid #404040;
}
.alias-table td {
padding: 10px 12px;
border-bottom: 1px solid #404040;
color: #e0e0e0;
}
.alias-table tr:hover {
background-color: #252525;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 20px;
}
.pagination button {
padding: 8px 16px;
background-color: #2d2d2d;
color: #ffffff;
border: 1px solid #404040;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.pagination button:hover:not(:disabled) {
background-color: #3d3d3d;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .page-info {
color: #b0b0b0;
font-size: 14px;
}
.config-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
}
.config-modal.active {
display: flex;
justify-content: center;
align-items: center;
}
.config-content {
background-color: #2d2d2d;
padding: 30px;
border-radius: 8px;
border: 1px solid #404040;
max-width: 500px;
width: 90%;
}
.config-content h2 {
color: #ffffff;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #e0e0e0;
}
.form-group input {
width: 100%;
padding: 10px;
background-color: #1a1a1a;
border: 1px solid #404040;
border-radius: 5px;
color: #ffffff;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: #0066cc;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
</style>
</head>
<body>
<div class="container">
<div class="header-bar">
<h1>Mailcow Alias Manager</h1>
<a href="/logout" class="btn btn-logout">Logout</a>
</div>
<div class="action-buttons">
<button class="btn" onclick="executeAction('list_aliases')">List All Aliases</button>
<button class="btn" onclick="executeAction('sync_aliases')">Sync Aliases</button>
<button class="btn" onclick="executeAction('get_domains')">Show Domains</button>
<button class="btn" onclick="openCreateAlias()">Create Alias</button>
<button class="btn" onclick="openDeleteAlias()">Delete Alias</button>
<button class="btn" onclick="openTimedAlias()">Create Timed Alias</button>
<button class="btn btn-config" onclick="openConfig()">Configuration</button>
</div>
<div class="search-section">
<h2 style="margin-bottom: 15px; color: #ffffff;">Search Aliases</h2>
<div class="search-container">
<input type="text" class="search-input" id="searchInput" placeholder="Search for alias or destination email...">
<button class="btn btn-primary" onclick="performSearch()">Search</button>
</div>
</div>
<div class="result-section">
<h2>Results</h2>
<div class="result-content" id="resultContent">
Ready to execute actions or perform searches...
</div>
</div>
</div>
<!-- Configuration Modal -->
<div class="config-modal" id="configModal">
<div class="config-content">
<h2>Configuration</h2>
<h3 style="color: #ffffff; margin-top: 20px; margin-bottom: 10px; font-size: 16px;">Mailcow Settings</h3>
<div class="form-group">
<label for="mailcowServer">Mailcow Server (without https://)</label>
<input type="text" id="mailcowServer" placeholder="mail.example.com">
</div>
<div class="form-group">
<label for="mailcowApiKey">Mailcow API Key</label>
<input type="text" id="mailcowApiKey" placeholder="Enter API key">
</div>
<h3 style="color: #ffffff; margin-top: 30px; margin-bottom: 10px; font-size: 16px;">Change Password</h3>
<div class="form-group">
<label for="oldPassword">Current Password</label>
<input type="password" id="oldPassword" placeholder="Enter current password">
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" placeholder="Enter new password">
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" placeholder="Confirm new password">
</div>
<div class="modal-buttons">
<button class="btn" onclick="closeConfig()">Cancel</button>
<button class="btn btn-primary" onclick="changePassword()">Change Password</button>
<button class="btn btn-primary" onclick="saveConfig()">Save Mailcow Config</button>
</div>
</div>
</div>
<!-- Create Alias Modal -->
<div class="config-modal" id="createAliasModal">
<div class="config-content">
<h2>Create New Alias</h2>
<div class="form-group">
<label for="newAlias">Alias Email</label>
<input type="text" id="newAlias" placeholder="alias@example.com">
</div>
<div class="form-group">
<label for="aliasGoto">Destination Email</label>
<input type="text" id="aliasGoto" placeholder="destination@example.com">
</div>
<div class="modal-buttons">
<button class="btn" onclick="closeCreateAlias()">Cancel</button>
<button class="btn btn-primary" onclick="createAlias()">Create</button>
</div>
</div>
</div>
<!-- Delete Alias Modal -->
<div class="config-modal" id="deleteAliasModal">
<div class="config-content">
<h2>Delete Alias</h2>
<div class="form-group">
<label for="deleteAliasEmail">Alias Email to Delete</label>
<input type="text" id="deleteAliasEmail" placeholder="alias@example.com">
</div>
<div class="modal-buttons">
<button class="btn" onclick="closeDeleteAlias()">Cancel</button>
<button class="btn btn-primary" onclick="deleteAlias()">Delete</button>
</div>
</div>
</div>
<!-- Timed Alias Modal -->
<div class="config-modal" id="timedAliasModal">
<div class="config-content">
<h2>Create Timed Alias</h2>
<div class="form-group">
<label for="timedUsername">Destination Email (user@domain.com)</label>
<input type="text" id="timedUsername" placeholder="user@example.com">
</div>
<div class="form-group">
<label for="timedDomain">Domain for New Alias</label>
<input type="text" id="timedDomain" placeholder="example.com">
</div>
<div class="modal-buttons">
<button class="btn" onclick="closeTimedAlias()">Cancel</button>
<button class="btn btn-primary" onclick="createTimedAlias()">Create</button>
</div>
</div>
</div>
<script>
let currentPage = 1;
function showResult(message, isSuccess = true) {
const resultContent = document.getElementById('resultContent');
resultContent.innerHTML = `<span class="${isSuccess ? 'success' : 'error'}">${message}</span>`;
}
function showAliasTable(data) {
const resultContent = document.getElementById('resultContent');
let html = `
<div style="color: #4CAF50; margin-bottom: 15px;">
Showing ${data.aliases.length} of ${data.total} aliases (Page ${data.page} of ${data.total_pages})
</div>
<table class="alias-table">
<thead>
<tr>
<th>Alias</th>
<th>Goes To</th>
</tr>
</thead>
<tbody>
`;
for (const alias of data.aliases) {
html += `
<tr>
<td>${alias.alias}</td>
<td>${alias.goto}</td>
</tr>
`;
}
html += `
</tbody>
</table>
<div class="pagination">
<button onclick="loadAliases(${data.page - 1})" ${!data.has_prev ? 'disabled' : ''}>Previous</button>
<span class="page-info">Page ${data.page} of ${data.total_pages}</span>
<button onclick="loadAliases(${data.page + 1})" ${!data.has_next ? 'disabled' : ''}>Next</button>
</div>
`;
resultContent.innerHTML = html;
}
function checkAuth(response) {
if (response.status === 401) {
window.location.href = '/login';
return true;
}
return false;
}
async function loadAliases(page = 1) {
currentPage = page;
try {
const response = await fetch('/list_aliases', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ page: page })
});
if (checkAuth(response)) return;
const result = await response.json();
if (result.status === 'success') {
showAliasTable(result.data);
} else {
showResult(result.message, false);
}
} catch (error) {
showResult(`Error loading aliases: ${error.message}`, false);
}
}
async function executeAction(actionName) {
if (actionName === 'list_aliases') {
loadAliases(1);
return;
}
try {
const response = await fetch(`/${actionName}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (checkAuth(response)) return;
const data = await response.json();
showResult(data.message, data.status === 'success');
} catch (error) {
showResult(`Error executing ${actionName}: ${error.message}`, false);
}
}
async function performSearch() {
const query = document.getElementById('searchInput').value;
if (!query.trim()) {
showResult('Please enter a search query', false);
return;
}
try {
const response = await fetch('/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: query })
});
if (checkAuth(response)) return;
const data = await response.json();
showResult(data.message, data.status === 'success');
} catch (error) {
showResult(`Search error: ${error.message}`, false);
}
}
// Allow search on Enter key
document.getElementById('searchInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
async function openConfig() {
try {
const response = await fetch('/config');
const config = await response.json();
document.getElementById('mailcowServer').value = config.mailcow_server || '';
document.getElementById('mailcowApiKey').value = config.mailcow_api_key || '';
// Clear password fields
document.getElementById('oldPassword').value = '';
document.getElementById('newPassword').value = '';
document.getElementById('confirmPassword').value = '';
document.getElementById('configModal').classList.add('active');
} catch (error) {
showResult(`Error loading config: ${error.message}`, false);
}
}
function closeConfig() {
document.getElementById('configModal').classList.remove('active');
}
async function saveConfig() {
const mailcowServer = document.getElementById('mailcowServer').value;
const mailcowApiKey = document.getElementById('mailcowApiKey').value;
try {
const response = await fetch('/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
mailcow_server: mailcowServer,
mailcow_api_key: mailcowApiKey
})
});
if (checkAuth(response)) return;
const data = await response.json();
showResult(data.message, data.status === 'success');
if (data.status === 'success') {
closeConfig();
}
} catch (error) {
showResult(`Error saving config: ${error.message}`, false);
}
}
async function changePassword() {
const oldPassword = document.getElementById('oldPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (!oldPassword || !newPassword || !confirmPassword) {
showResult('All password fields are required', false);
return;
}
try {
const response = await fetch('/change_password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword,
confirm_password: confirmPassword
})
});
if (checkAuth(response)) return;
const data = await response.json();
showResult(data.message, data.status === 'success');
if (data.status === 'success') {
// Clear password fields on success
document.getElementById('oldPassword').value = '';
document.getElementById('newPassword').value = '';
document.getElementById('confirmPassword').value = '';
}
} catch (error) {
showResult(`Error changing password: ${error.message}`, false);
}
}
// Create Alias Modal Functions
function openCreateAlias() {
document.getElementById('createAliasModal').classList.add('active');
}
function closeCreateAlias() {
document.getElementById('createAliasModal').classList.remove('active');
}
async function createAlias() {
const alias = document.getElementById('newAlias').value;
const goto = document.getElementById('aliasGoto').value;
if (!alias || !goto) {
showResult('Both alias and destination are required', false);
return;
}
try {
const response = await fetch('/create_alias', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ alias: alias, goto: goto })
});
const data = await response.json();
showResult(data.message, data.status === 'success');
if (data.status === 'success') {
document.getElementById('newAlias').value = '';
document.getElementById('aliasGoto').value = '';
closeCreateAlias();
}
} catch (error) {
showResult(`Error creating alias: ${error.message}`, false);
}
}
// Delete Alias Modal Functions
function openDeleteAlias() {
document.getElementById('deleteAliasModal').classList.add('active');
}
function closeDeleteAlias() {
document.getElementById('deleteAliasModal').classList.remove('active');
}
async function deleteAlias() {
const alias = document.getElementById('deleteAliasEmail').value;
if (!alias) {
showResult('Alias email is required', false);
return;
}
try {
const response = await fetch('/delete_alias', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ alias: alias })
});
const data = await response.json();
showResult(data.message, data.status === 'success');
if (data.status === 'success') {
document.getElementById('deleteAliasEmail').value = '';
closeDeleteAlias();
}
} catch (error) {
showResult(`Error deleting alias: ${error.message}`, false);
}
}
// Timed Alias Modal Functions
function openTimedAlias() {
document.getElementById('timedAliasModal').classList.add('active');
}
function closeTimedAlias() {
document.getElementById('timedAliasModal').classList.remove('active');
}
async function createTimedAlias() {
const username = document.getElementById('timedUsername').value;
const domain = document.getElementById('timedDomain').value;
if (!username || !domain) {
showResult('Both username and domain are required', false);
return;
}
try {
const response = await fetch('/create_timed_alias', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username: username, domain: domain })
});
const data = await response.json();
showResult(data.message, data.status === 'success');
if (data.status === 'success') {
document.getElementById('timedUsername').value = '';
document.getElementById('timedDomain').value = '';
closeTimedAlias();
}
} catch (error) {
showResult(`Error creating timed alias: ${error.message}`, false);
}
}
// Close modals when clicking outside
document.getElementById('configModal').addEventListener('click', function(e) {
if (e.target === this) closeConfig();
});
document.getElementById('createAliasModal').addEventListener('click', function(e) {
if (e.target === this) closeCreateAlias();
});
document.getElementById('deleteAliasModal').addEventListener('click', function(e) {
if (e.target === this) closeDeleteAlias();
});
document.getElementById('timedAliasModal').addEventListener('click', function(e) {
if (e.target === this) closeTimedAlias();
});
</script>
</body>
</html>

159
templates/login.html Normal file
View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Mailcow Alias Manager</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #1a1a1a;
color: #e0e0e0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-container {
background-color: #2d2d2d;
padding: 40px;
border-radius: 8px;
border: 1px solid #404040;
max-width: 400px;
width: 100%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
h1 {
color: #ffffff;
margin-bottom: 10px;
text-align: center;
font-size: 24px;
}
.subtitle {
color: #b0b0b0;
text-align: center;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #e0e0e0;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px;
background-color: #1a1a1a;
border: 1px solid #404040;
border-radius: 5px;
color: #ffffff;
font-size: 16px;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #0066cc;
}
.btn {
width: 100%;
background-color: #0066cc;
color: #ffffff;
border: none;
padding: 12px 24px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s ease;
}
.btn:hover {
background-color: #0052a3;
}
.btn:active {
background-color: #004080;
}
.error-message {
color: #f44336;
margin-top: 15px;
text-align: center;
font-size: 14px;
display: none;
}
.error-message.show {
display: block;
}
</style>
</head>
<body>
<div class="login-container">
<h1>Mailcow Alias Manager</h1>
<p class="subtitle">Please enter your password to continue</p>
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required autofocus>
</div>
<button type="submit" class="btn">Login</button>
<div id="errorMessage" class="error-message"></div>
</form>
</div>
<script>
async function handleLogin(event) {
event.preventDefault();
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('errorMessage');
try {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ password: password })
});
const data = await response.json();
if (data.status === 'success') {
window.location.href = '/';
} else {
errorMessage.textContent = data.message;
errorMessage.classList.add('show');
document.getElementById('password').value = '';
document.getElementById('password').focus();
}
} catch (error) {
errorMessage.textContent = 'Login error: ' + error.message;
errorMessage.classList.add('show');
}
}
</script>
</body>
</html>