commit 2f4d41092cc0d31b3027e3389ca14361cd9cd8be Author: Rune Olsen Date: Mon Jan 12 14:09:28 2026 +0100 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..deb3779 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba5702f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..127c778 --- /dev/null +++ b/DOCKER.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3efdede --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cf41a6 --- /dev/null +++ b/README.md @@ -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` diff --git a/app.py b/app.py new file mode 100644 index 0000000..9841ee2 --- /dev/null +++ b/app.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..63cebd3 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/malias.py b/malias.py new file mode 100644 index 0000000..411f344 --- /dev/null +++ b/malias.py @@ -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') + diff --git a/malias_wrapper.py b/malias_wrapper.py new file mode 100644 index 0000000..77b7318 --- /dev/null +++ b/malias_wrapper.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bcc4fd5 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..3ff2535 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,750 @@ + + + + + + Internal Web App + + + +
+
+

Mailcow Alias Manager

+ Logout +
+ +
+ + + + + + + +
+ +
+

Search Aliases

+
+ + +
+
+ +
+

Results

+
+ Ready to execute actions or perform searches... +
+
+
+ + +
+
+

Configuration

+ +

Mailcow Settings

+
+ + +
+
+ + +
+ +

Change Password

+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+

Create New Alias

+
+ + +
+
+ + +
+ +
+
+ + +
+
+

Delete Alias

+
+ + +
+ +
+
+ + +
+
+

Create Timed Alias

+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..85af8c6 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,159 @@ + + + + + + Login - Mailcow Alias Manager + + + +
+

Mailcow Alias Manager

+

Please enter your password to continue

+ +
+
+ + +
+ + + +
+
+
+ + + +