diff --git a/Dockerfile b/Dockerfile index f2a1184..a8b2f47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,11 +17,17 @@ COPY static/ static/ # Create data directory RUN mkdir -p /app/data +# Copy entrypoint script +COPY docker-entrypoint.sh . +RUN chmod +x docker-entrypoint.sh + # Expose port EXPOSE 5172 # Set environment variables ENV PYTHONUNBUFFERED=1 +ENV FLASK_DEBUG=False +ENV FLASK_ENV=production -# Run the application -CMD ["python", "app.py"] +# Run the application via entrypoint +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4455f4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rune Olsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app.py b/app.py index 9841ee2..30a4cb1 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,8 @@ from flask import Flask, render_template, request, jsonify, redirect, url_for, s from functools import wraps import malias_wrapper as malias_w import os +import argparse +import sys app = Flask(__name__) app.secret_key = os.urandom(24) # Secret key for session management @@ -123,6 +125,60 @@ def delete_alias_route(): except Exception as e: return jsonify({'status': 'error', 'message': f"Error deleting alias: {str(e)}"}) +@app.route('/delete_aliases_bulk', methods=['POST']) +@login_required +def delete_aliases_bulk(): + """Delete multiple aliases at once""" + try: + data = request.json + aliases = data.get('aliases', []) + + if not aliases or not isinstance(aliases, list): + return jsonify({'status': 'error', 'message': 'Aliases array is required'}) + + if len(aliases) == 0: + return jsonify({'status': 'error', 'message': 'No aliases provided'}) + + # Delete multiple aliases + result = malias_w.delete_multiple_aliases(aliases) + + # Build response message + success_count = result['success_count'] + failed_count = result['failed_count'] + total_count = success_count + failed_count + + if failed_count == 0: + message = f"{success_count} of {total_count} aliases deleted successfully" + return jsonify({ + 'status': 'success', + 'message': message, + 'details': result + }) + elif success_count == 0: + # All failed + failed_list = ', '.join([f['alias'] for f in result['failed_aliases'][:3]]) + if len(result['failed_aliases']) > 3: + failed_list += f" and {len(result['failed_aliases']) - 3} more" + message = f"Failed to delete aliases: {failed_list}" + return jsonify({ + 'status': 'error', + 'message': message, + 'details': result + }) + else: + # Partial success + failed_list = ', '.join([f['alias'] for f in result['failed_aliases'][:3]]) + if len(result['failed_aliases']) > 3: + failed_list += f" and {len(result['failed_aliases']) - 3} more" + message = f"{success_count} of {total_count} aliases deleted successfully. Failed: {failed_list}" + return jsonify({ + 'status': 'partial', + 'message': message, + 'details': result + }) + except Exception as e: + return jsonify({'status': 'error', 'message': f"Error deleting aliases: {str(e)}"}) + @app.route('/create_timed_alias', methods=['POST']) @login_required def create_timed_alias(): @@ -209,4 +265,82 @@ def change_password_route(): return jsonify({'status': 'error', 'message': str(e)}) if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0', port=5172) + # Parse command-line arguments + parser = argparse.ArgumentParser( + description='Mailcow Alias Manager - Web interface for managing mail aliases', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python3 app.py Run in normal mode on port 5172 + python3 app.py --debug Run in debug mode with auto-reload + python3 app.py --port 8080 Run on custom port 8080 + python3 app.py --debug --port 8080 Run in debug mode on port 8080 + python3 app.py --host 127.0.0.1 Bind to localhost only + +Environment Variables (Docker): + FLASK_DEBUG Set to 'True' for debug mode (default: False) + FLASK_PORT Port to run on (default: 5172) + FLASK_HOST Host to bind to (default: 0.0.0.0) + FLASK_ENV Set to 'production' or 'development' + ''' + ) + + parser.add_argument( + '--debug', + action='store_true', + help='Enable debug mode with auto-reload and detailed error messages' + ) + + parser.add_argument( + '--port', + type=int, + default=None, + help='Port to run on (default: 5172 or FLASK_PORT env var)' + ) + + parser.add_argument( + '--host', + type=str, + default=None, + help='Host to bind to (default: 0.0.0.0 or FLASK_HOST env var)' + ) + + args = parser.parse_args() + + # Priority: Command-line args > Environment variables > Defaults + # Debug mode + if args.debug: + debug_mode = True + else: + debug_mode = os.getenv('FLASK_DEBUG', 'False').lower() in ('true', '1', 'yes') + + # Port + if args.port is not None: + port = args.port + else: + port = int(os.getenv('FLASK_PORT', '5172')) + + # Host + if args.host is not None: + host = args.host + else: + host = os.getenv('FLASK_HOST', '0.0.0.0') + + # Print startup info + print('=' * 60) + print(' Mailcow Alias Manager - Starting...') + print('=' * 60) + print(f' Mode: {"DEBUG (Development)" if debug_mode else "NORMAL (Production)"}') + print(f' Host: {host}') + print(f' Port: {port}') + print(f' URL: http://{host}:{port}') + print('=' * 60) + if debug_mode: + print(' ⚠️ WARNING: Debug mode is enabled!') + print(' - Auto-reload on code changes') + print(' - Detailed error messages shown') + print(' - NOT suitable for production use') + print('=' * 60) + print() + + app.run(debug=debug_mode, host=host, port=port) diff --git a/docker-compose.yml b/docker-compose.yml index 63cebd3..5b457a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,14 @@ +# Mailcow Alias Manager - Docker Compose Configuration +# +# Production vs Development Mode: +# -------------------------------- +# FLASK_ENV=production + FLASK_DEBUG=False → Uses Gunicorn (4 workers, production-ready) +# FLASK_ENV=development or FLASK_DEBUG=True → Uses Flask dev server (auto-reload, debug info) +# +# Recommended Settings: +# - Production: FLASK_ENV=production, FLASK_DEBUG=False (default) +# - Development: FLASK_ENV=development, FLASK_DEBUG=True + services: mailcow-alias-manager: image: gitlab.pm/rune/malias-web:latest @@ -8,4 +19,17 @@ services: - ./data:/app/data restart: unless-stopped environment: - - TZ=Europe/Oslo \ No newline at end of file + - TZ=Europe/Oslo + + # Flask Configuration + # Set to 'production' for Gunicorn (recommended) or 'development' for Flask dev server + - FLASK_ENV=production + + # Debug mode: False for production (recommended), True for development/troubleshooting + - FLASK_DEBUG=False + + # Port configuration (default: 5172) + - FLASK_PORT=5172 + + # Host binding (default: 0.0.0.0 for Docker) + - FLASK_HOST=0.0.0.0 \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..21ca574 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +# Get environment variables with defaults +FLASK_DEBUG="${FLASK_DEBUG:-False}" +FLASK_ENV="${FLASK_ENV:-production}" +FLASK_PORT="${FLASK_PORT:-5172}" +FLASK_HOST="${FLASK_HOST:-0.0.0.0}" + +echo "Starting Mailcow Alias Manager..." +echo "Environment: $FLASK_ENV" +echo "Debug mode: $FLASK_DEBUG" +echo "Port: $FLASK_PORT" + +# Check if we should run in production mode +if [ "$FLASK_ENV" = "production" ] && [ "$FLASK_DEBUG" != "True" ] && [ "$FLASK_DEBUG" != "true" ] && [ "$FLASK_DEBUG" != "1" ]; then + echo "Starting with Gunicorn (production mode)..." + exec gunicorn --bind $FLASK_HOST:$FLASK_PORT \ + --workers 4 \ + --threads 2 \ + --timeout 120 \ + --access-logfile - \ + --error-logfile - \ + --log-level info \ + "app:app" +else + echo "Starting with Flask development server (debug mode)..." + exec python app.py +fi diff --git a/malias_wrapper.py b/malias_wrapper.py index 77b7318..221043a 100644 --- a/malias_wrapper.py +++ b/malias_wrapper.py @@ -107,12 +107,12 @@ def get_config(): return {'mailcow_server': '', 'mailcow_api_key': ''} def search_aliases(query): - """Search for aliases in local database""" + """Search for aliases in local database (matches from start of alias address only)""" 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)) + search_term = query + '%' # Match from start only + cursor.execute('SELECT alias, goto FROM aliases WHERE alias LIKE ?', + (search_term,)) results = cursor.fetchall() conn.close() return results @@ -384,3 +384,37 @@ def change_password(old_password, new_password): conn.close() return True + +def delete_multiple_aliases(alias_list): + """ + Delete multiple aliases in one operation + + Args: + alias_list: List of alias email addresses to delete + + Returns: + Dictionary with: + - success_count: Number of successfully deleted aliases + - failed_count: Number of failed deletions + - failed_aliases: List of dicts with 'alias' and 'error' for each failure + """ + success_count = 0 + failed_count = 0 + failed_aliases = [] + + for alias in alias_list: + try: + delete_alias(alias) + success_count += 1 + except Exception as e: + failed_count += 1 + failed_aliases.append({ + 'alias': alias, + 'error': str(e) + }) + + return { + 'success_count': success_count, + 'failed_count': failed_count, + 'failed_aliases': failed_aliases + } diff --git a/requirements.txt b/requirements.txt index bcc4fd5..d1ce881 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ python-dotenv==1.0.0 httpx==0.27.0 rich==13.7.0 bcrypt==4.1.2 +gunicorn==21.2.0 diff --git a/templates/index.html b/templates/index.html index 5c96251..f094181 100644 --- a/templates/index.html +++ b/templates/index.html @@ -440,6 +440,94 @@ .form-group input { font-size: 16px; } + + /* Delete modal responsive styles */ + .delete-results-table { + font-size: 14px; + } + + .delete-results-table thead { + display: none; + } + + .delete-results-table tbody { + display: block; + } + + .delete-results-table tr { + display: block; + margin-bottom: 15px; + background-color: #252525; + border-radius: 5px; + padding: 50px 15px 15px 15px; + border: 1px solid #404040; + position: relative; + } + + .delete-results-table td { + display: block; + text-align: left; + padding: 6px 0; + border: none; + } + + /* Checkbox in top-left corner */ + .delete-results-table td:first-child { + position: absolute; + top: 15px; + left: 15px; + padding: 0; + } + + .delete-checkbox { + width: 24px; + height: 24px; + } + + /* Trash icon in top-right corner */ + .delete-results-table td:last-child { + position: absolute; + top: 15px; + right: 15px; + padding: 0; + } + + .delete-icon { + font-size: 24px; + } + + /* Alias content with labels */ + .delete-results-table td:nth-child(2)::before { + content: "Alias: "; + font-weight: bold; + color: #0066cc; + } + + .delete-results-table td:nth-child(3)::before { + content: "Goes to: "; + font-weight: bold; + color: #0066cc; + } + + /* Bulk controls stack vertically */ + .bulk-controls { + flex-direction: column; + } + + .bulk-controls .btn { + width: 100%; + } + + /* Delete confirm modal */ + .delete-confirm-list { + max-height: 200px; + } + + /* Results warning */ + .results-warning { + font-size: 13px; + padding: 8px; + } } /* Tablet Styles */ @@ -456,6 +544,25 @@ margin-left: 0; flex: 1 1 100%; } + + /* Delete table on tablets - keep table format but adjust sizing */ + .delete-results-table { + font-size: 14px; + } + + .delete-results-table th, + .delete-results-table td { + padding: 10px 8px; + } + + .delete-checkbox { + width: 22px; + height: 22px; + } + + .delete-icon { + font-size: 22px; + } } /* Small Mobile Devices */ @@ -472,6 +579,147 @@ .result-section h2 { font-size: 16px; } + + /* Delete modal adjustments for very small screens */ + .config-content { + padding: 15px 12px; + } + + .delete-results-table tr { + padding: 45px 12px 12px 12px; + } + + .delete-results-table td:first-child { + top: 12px; + left: 12px; + } + + .delete-results-table td:last-child { + top: 12px; + right: 12px; + } + + .delete-confirm-list { + max-height: 150px; + font-size: 14px; + } + } + + /* Delete Aliases Modal Specific Styles */ + .delete-checkbox { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: #0066cc; + margin: 0; + } + + .delete-icon { + cursor: pointer; + font-size: 20px; + transition: transform 0.2s, filter 0.2s; + display: inline-block; + user-select: none; + } + + .delete-icon:hover { + transform: scale(1.3); + filter: brightness(1.4); + } + + .delete-icon:active { + transform: scale(1.1); + } + + .delete-results-table { + width: 100%; + border-collapse: collapse; + } + + .delete-results-table th { + background-color: #1a1a1a; + color: #ffffff; + padding: 12px; + text-align: left; + border-bottom: 2px solid #404040; + font-weight: 600; + } + + .delete-results-table td { + padding: 12px; + border-bottom: 1px solid #404040; + color: #e0e0e0; + } + + .delete-table-row { + transition: background-color 0.2s; + } + + .delete-table-row:hover { + background-color: #252525; + } + + .delete-table-row.selected { + background-color: #1a3a5a !important; + } + + .delete-table-row.selected:hover { + background-color: #244b6b !important; + } + + .results-warning { + color: #ff9800; + font-size: 14px; + margin-top: 10px; + padding: 10px; + background-color: rgba(255, 152, 0, 0.1); + border-radius: 5px; + border-left: 3px solid #ff9800; + } + + .delete-confirm-list { + margin: 15px 0; + padding: 15px; + background-color: #1a1a1a; + border-radius: 5px; + max-height: 300px; + overflow-y: auto; + } + + .delete-confirm-list ul { + list-style: none; + padding: 0; + margin: 0; + } + + .delete-confirm-list li { + padding: 8px 0; + border-bottom: 1px solid #404040; + color: #e0e0e0; + } + + .delete-confirm-list li:last-child { + border-bottom: none; + } + + .delete-confirm-list li::before { + content: "• "; + color: #f44336; + font-weight: bold; + margin-right: 8px; + } + + /* Column widths for delete table */ + .delete-results-table th:first-child, + .delete-results-table td:first-child { + width: 50px; + text-align: center; + } + + .delete-results-table th:last-child, + .delete-results-table td:last-child { + width: 60px; + text-align: center; } @@ -487,7 +735,7 @@ - + @@ -495,7 +743,7 @@
This action cannot be undone.
+| Select | +Alias | +Goes To | +Delete | +
|---|---|---|---|
| + + | +${escapeHtml(result.alias)} | +${escapeHtml(result.goto)} | ++ + | +
You are about to delete ${aliasArray.length} alias(es):
+