Compare commits

22 Commits
1.0.3 ... main

Author SHA1 Message Date
d4386f53f5 Updated docker compose file 2026-01-26 09:15:03 +01:00
8f443379cd Auto login with Authelia 2026-01-23 14:07:09 +01:00
a47b57f26a Auto login with Authelia 2026-01-23 13:59:30 +01:00
cd401e108f Auto login with Authelia 2026-01-23 13:51:41 +01:00
3d11470f81 Auto login with Authelia 2026-01-23 13:41:39 +01:00
877ecf32b4 Fixed login with only ip:port access 2026-01-23 13:32:19 +01:00
3b78959901 New changes after reset 2026-01-23 13:14:19 +01:00
6e110289cd Changed app for proxy and https++ 2026-01-23 10:39:52 +01:00
afab617c5c Changed app for proxy and https++ 2026-01-23 09:48:57 +01:00
b737826d11 Changed app for proxy and https++ 2026-01-23 09:36:32 +01:00
f9704f6dd3 Changed app for proxy and https++ 2026-01-23 09:30:05 +01:00
75a3ec9d7e Changed app for proxy and https++ 2026-01-23 08:38:34 +01:00
21f27e0d27 Changed app for proxy and https++ 2026-01-22 16:52:46 +01:00
7ed6100f05 Changed app for proxy and https++ 2026-01-22 16:49:12 +01:00
c3efa127d9 Changed app for proxy and https++ 2026-01-22 16:42:55 +01:00
e4fed12143 Changed app for proxy and https++ 2026-01-22 16:32:02 +01:00
7fc81a0f65 dockerfile 2026-01-22 16:26:21 +01:00
611031ec6c Added reset password because - I do not wanna talk about it 2026-01-22 16:19:04 +01:00
686bce2df9 Merge remote changes with local bulk delete feature
- Resolved conflict in templates/index.html
- Kept local delete modal responsive styles
- Merged documentation updates from remote (DOCKER.md, README.md)
2026-01-16 11:24:42 +01:00
efbb5725d3 Merge branch '1.0.4' 2026-01-16 11:19:07 +01:00
d444bb5fcb Updated and fixes. v1.0.4 2026-01-16 11:13:32 +01:00
acd37a9c15 Merge pull request 'Small changes, added license' (#1) from 1.0.3 into main
Reviewed-on: #1
2026-01-13 14:25:10 +01:00
12 changed files with 1871 additions and 40 deletions

2
.gitignore vendored
View File

@@ -14,3 +14,5 @@ BUI*
build.sh
docker-compose.dist*
GITEA.md
PROXY_AUTHELIA_SETUP.md
alias.rune.pm.config

View File

@@ -11,17 +11,25 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY malias_wrapper.py .
COPY malias.py .
COPY reset_password.py .
COPY templates/ templates/
COPY static/ static/
# Create data directory
RUN mkdir -p /app/data
# Copy entrypoint script and make scripts executable
COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh reset_password.py
# Expose port
EXPOSE 5172
# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV FLASK_DEBUG=False
ENV FLASK_ENV=production
ENV ENABLE_PROXY=false
# Run the application
CMD ["python", "app.py"]
# Run the application via entrypoint
ENTRYPOINT ["./docker-entrypoint.sh"]

432
PROXY_SETUP.md Normal file
View File

@@ -0,0 +1,432 @@
# Reverse Proxy Setup Guide
This guide helps you configure reverse proxies (Nginx, Traefik, Zoraxy, Authelia, Caddy, etc.) to work with Mailcow Alias Manager.
---
## 🔧 ENABLE_PROXY Configuration
The application supports **two access modes** controlled by the `ENABLE_PROXY` environment variable in `docker-compose.yml`:
### **Mode 1: Direct Access (ENABLE_PROXY=false)** - DEFAULT
Use this when accessing the application directly via IP:port **without a reverse proxy**.
**docker-compose.yml:**
```yaml
environment:
- ENABLE_PROXY=false # Default setting
```
**Access:** `http://192.168.1.100:5172` (replace with your server IP)
**Features:**
- ✅ Works over HTTP (no HTTPS required)
- ✅ Standard cookie behavior (SameSite=Lax)
- ✅ No proxy configuration needed
- ✅ Simple login flow
- ✅ Perfect for internal/LAN access
**When to use:**
- Accessing from internal network only
- No reverse proxy in place
- Testing or development
- Simple single-server setup
---
### **Mode 2: Proxy Access (ENABLE_PROXY=true)**
Use this when running **behind a reverse proxy** (Authelia, Zoraxy, Nginx, Traefik, Caddy).
**docker-compose.yml:**
```yaml
environment:
- ENABLE_PROXY=true
```
**Access:** `https://alias.yourdomain.com` (through your reverse proxy)
**Features:**
- ✅ ProxyFix middleware handles X-Forwarded-* headers
- ✅ HTTPS redirect support
- ✅ Secure cookies (HTTPS only)
- ✅ Works with authentication proxies (Authelia)
- ✅ Multi-proxy chain support
**When to use:**
- Accessing from internet via domain name
- Behind Nginx, Traefik, Caddy, HAProxy
- Behind authentication proxy (Authelia, Authentik)
- SSL/TLS termination at proxy
- Production deployments with HTTPS
**Special Feature - Authelia Auto-Login:**
When `ENABLE_PROXY=true` and you're using Authelia, the app automatically logs you in using Authelia's authentication headers. **No app password needed!** Simply authenticate through Authelia, and you'll be logged into the Mailcow Alias Manager automatically.
---
### **Switching Between Modes**
To switch from one mode to another:
1. **Edit `docker-compose.yml`**
```yaml
# Change this line:
- ENABLE_PROXY=false # or true
```
2. **Restart the container**
```bash
docker compose down
docker compose up -d
```
3. **Verify mode in logs**
```bash
docker compose logs mailcow-alias-manager | grep "ACCESS MODE"
```
You should see either:
- `ACCESS MODE: Direct IP:Port (ENABLE_PROXY=false)`
- `ACCESS MODE: Reverse Proxy (ENABLE_PROXY=true)`
4. **Clear browser cookies** (IMPORTANT!)
- Press F12 → Application → Cookies
- Delete all cookies for your domain
- Close and reopen browser
5. **Login again**
---
### **Quick Reference Table**
| Access Method | ENABLE_PROXY | Access URL | Cookie Mode |
|--------------|--------------|------------|-------------|
| Direct IP:port | `false` (default) | `http://192.168.1.100:5172` | HTTP, SameSite=Lax |
| Nginx/Traefik | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
| Authelia + Zoraxy | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
| Caddy | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
| Local dev (`python3 app.py`) | N/A (not set) | `http://localhost:5172` | HTTP, SameSite=Lax |
---
## ✅ Built-in Proxy Support (When ENABLE_PROXY=true)
The application includes **ProxyFix middleware** that automatically handles:
- ✅ HTTPS detection via `X-Forwarded-Proto`
- ✅ Host header forwarding via `X-Forwarded-Host`
- ✅ Client IP forwarding via `X-Forwarded-For`
- ✅ Path prefix support via `X-Forwarded-Prefix`
**Works with 2 proxies in chain** (e.g., Zoraxy → Authelia → App)
---
## Common Proxy Configurations
### **Zoraxy** (Your Current Setup)
Zoraxy automatically sets the required headers. Just configure:
1. **Upstream Target**: `http://localhost:5172` (or your Docker IP)
2. **Enable WebSocket**: Yes (optional, recommended)
3. **Enable Proxy Headers**: Yes (should be default)
**Example Zoraxy Config:**
```json
{
"name": "mailcow-alias-manager",
"upstream": "http://localhost:5172",
"domain": "aliases.yourdomain.com"
}
```
---
### **Authelia** (Authentication Proxy)
If using Authelia in front of the app:
1. **Authelia forwards the user after login** - this should work automatically
2. **Make sure Authelia passes these headers**:
- `X-Forwarded-Proto`
- `X-Forwarded-Host`
- `X-Forwarded-For`
**Common Issue**: Authelia redirects → App login page
**Solution**: The app has its own authentication. You have two options:
- **Option A**: Disable app login (requires code modification)
- **Option B**: Use Authelia for network access, app login for API access
---
### **Nginx**
```nginx
server {
listen 443 ssl http2;
server_name aliases.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:5172;
# Required headers for ProxyFix
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Optional: WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
---
### **Traefik**
**docker-compose.yml:**
```yaml
services:
mailcow-alias-manager:
image: gitlab.pm/rune/malias-web:latest
container_name: mailcow-alias-manager
volumes:
- ./data:/app/data
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.malias.rule=Host(`aliases.yourdomain.com`)"
- "traefik.http.routers.malias.entrypoints=websecure"
- "traefik.http.routers.malias.tls=true"
- "traefik.http.routers.malias.tls.certresolver=letsencrypt"
- "traefik.http.services.malias.loadbalancer.server.port=5172"
networks:
- traefik
networks:
traefik:
external: true
```
Traefik automatically sets `X-Forwarded-*` headers by default.
---
### **Caddy**
```caddy
aliases.yourdomain.com {
reverse_proxy localhost:5172
}
```
Caddy automatically handles all proxy headers - no configuration needed!
---
## 🔧 Troubleshooting
### **Issue: "NetworkError when attempting to fetch resource"**
**Causes:**
1. Mixed HTTP/HTTPS content
2. CORS blocking requests
3. Session cookie not being set
4. Proxy not forwarding headers
**Solutions:**
#### **1. Check Proxy Headers**
Your proxy MUST forward these headers:
```
X-Forwarded-Proto: https
X-Forwarded-Host: aliases.yourdomain.com
X-Forwarded-For: <client-ip>
```
#### **2. Check Browser Console**
Open browser DevTools (F12) → Console tab → Look for errors:
- **CORS errors**: Proxy misconfiguration
- **Mixed content**: HTTP resources on HTTPS page
- **401 Unauthorized**: Session/cookie issue
#### **3. Test Direct Access**
```bash
# Test without proxy
curl http://localhost:5172/
# Should return HTML (login page)
```
If this works but proxy doesn't, it's a proxy configuration issue.
#### **4. Check Session Cookies**
In browser DevTools → Application tab → Cookies:
- **Domain**: Should match your proxy domain
- **Path**: `/`
- **HttpOnly**: Should be `true`
- **Secure**: Depends on your setup
---
### **Issue: Login works but redirects to HTTP instead of HTTPS**
**Solution**: Already fixed in latest version via `PREFERRED_URL_SCHEME='https'` in app config.
If still happening:
1. Verify proxy sends `X-Forwarded-Proto: https`
2. Check `docker compose logs -f mailcow-alias-manager`
3. Rebuild image: `docker compose up -d --build`
---
### **Issue: "Invalid password" but password is correct**
This is NOT a proxy issue - this is an authentication issue:
```bash
# Reset password
docker exec -it mailcow-alias-manager python3 reset_password.py "newpass"
```
---
### **Issue: 502 Bad Gateway**
**Causes:**
1. App not running
2. Wrong upstream port
3. Container network issue
**Check:**
```bash
# Is container running?
docker ps | grep mailcow-alias-manager
# Check logs
docker compose logs -f mailcow-alias-manager
# Test internal connectivity
docker exec mailcow-alias-manager curl http://localhost:5172
```
---
## 🔒 Security Best Practices
### **1. HTTPS Only**
Always use HTTPS in production. HTTP is only for local testing.
### **2. Restrict Access**
Use firewall or proxy rules to restrict access:
```nginx
# Nginx: Restrict to internal network only
allow 192.168.1.0/24;
deny all;
```
### **3. Use Authelia/Authentik**
Add SSO layer for additional security:
- Users authenticate via Authelia
- Then app login provides API access
- Two-factor authentication recommended
### **4. Keep Session Cookies Secure**
The app sets:
- `HttpOnly=True` - Prevents JavaScript access
- `SameSite=Lax` - CSRF protection
For HTTPS-only deployments, you can enforce secure cookies:
```python
# In app.py, change:
SESSION_COOKIE_SECURE=True # Only send cookie over HTTPS
```
---
## 📊 Testing Your Setup
### **1. Basic Connectivity**
```bash
curl -v https://aliases.yourdomain.com/
# Should return 200 OK with HTML
```
### **2. Login Test**
```bash
curl -v -X POST https://aliases.yourdomain.com/login \
-H "Content-Type: application/json" \
-d '{"password":"yourpassword"}' \
-c cookies.txt
# Check response - should be JSON with status: success
```
### **3. Session Test**
```bash
curl -v https://aliases.yourdomain.com/ \
-b cookies.txt
# Should return main page HTML (not login redirect)
```
---
## 🆘 Still Having Issues?
### **Enable Debug Logging**
**docker-compose.yml:**
```yaml
environment:
- FLASK_DEBUG=True # Enable debug mode
```
Restart:
```bash
docker compose down
docker compose up -d
docker compose logs -f
```
### **Check Application Logs**
```bash
# View logs
docker compose logs -f mailcow-alias-manager
# Check for errors
docker compose logs mailcow-alias-manager | grep -i error
```
### **Test Without Proxy**
Temporarily bypass proxy to isolate the issue:
```bash
# Direct access test
curl http://localhost:5172/
```
If this works, the issue is with proxy configuration, not the app.
---
## ✅ Summary
**The app is already configured for reverse proxies!**
Key points:
- ✅ ProxyFix middleware is enabled
- ✅ Handles X-Forwarded-* headers automatically
- ✅ HTTPS redirect support built-in
- ✅ Session cookies work behind proxies
- ✅ No code changes needed
Just make sure your proxy forwards the standard headers and you're good to go! 🚀

View File

@@ -1,4 +1,4 @@
# Mailcow Alias Manager Web Interface
# 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. This application uses [malias](https://gitlab.pm/rune/malias) as base. Malias it self is a CLI application for managing aliases on a Mailcow Instance. If you already are using the CLI instance you can copy the the DB file over to your `/data` directory and you'll keep you API key etc. If you start with a blank DB you can use the `Sync Aliases` function to populate existing aliases to your local db.
@@ -68,7 +68,32 @@ The application will be available at: `http://localhost:5172`
## Configuration
### 1. Configure Mailcow Connection
### 1. Access Mode Configuration
The application supports two access modes:
**Direct Access (Default)** - Access via IP:port
- Set `ENABLE_PROXY=false` in `docker-compose.yml` (default)
- Access at: `http://your-server-ip:5172`
- Works over HTTP (no HTTPS required)
- **Login:** Enter app password
- Perfect for internal/LAN access
**Proxy Access** - Access via reverse proxy (Authelia, Nginx, Traefik, etc.)
- Set `ENABLE_PROXY=true` in `docker-compose.yml`
- Access at: `https://alias.yourdomain.com` (through your proxy)
- Requires HTTPS
- **Login:** Automatic when using Authelia (no password needed!)
- See [PROXY_SETUP.md](PROXY_SETUP.md) for detailed configuration
**To switch modes:**
1. Edit `docker-compose.yml` and change `ENABLE_PROXY` value
2. Restart: `docker compose down && docker compose up -d`
3. Clear browser cookies and login again
---
### 2. Configure Mailcow Connection
You can configure your Mailcow server connection either:

602
app.py
View File

@@ -1,51 +1,350 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
from flask import Flask, render_template, request, jsonify, redirect, url_for, session, make_response
from functools import wraps
from werkzeug.middleware.proxy_fix import ProxyFix
from datetime import timedelta
import malias_wrapper as malias_w
import os
import argparse
import sys
import secrets
import logging
import json
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('mailcow-alias-manager')
app = Flask(__name__)
app.secret_key = os.urandom(24) # Secret key for session management
app.secret_key = os.getenv('SECRET_KEY', 'malias-default-secret-key-please-change') # Consistent secret key
# Configure proxy mode based on environment variable
# ENABLE_PROXY=true: Behind reverse proxy (Authelia/Zoraxy/Nginx/etc.)
# ENABLE_PROXY=false: Direct IP:port access (default)
ENABLE_PROXY = os.getenv('ENABLE_PROXY', 'false').lower() in ('true', '1', 'yes')
if ENABLE_PROXY:
# Mode: Behind reverse proxy (Authelia/Zoraxy/Nginx/etc.)
logger.info("=" * 60)
logger.info(" ACCESS MODE: Reverse Proxy (ENABLE_PROXY=true)")
logger.info("=" * 60)
logger.info(" Configuration:")
logger.info(" - ProxyFix middleware: ACTIVE")
logger.info(" - Trusted proxies: 2 (e.g., Zoraxy + Authelia)")
logger.info(" - Cookie security: HTTPS only (Secure=True)")
logger.info(" - SameSite policy: None (cross-origin auth)")
logger.info(" - URL scheme: https://")
logger.info("=" * 60)
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=2, # Trust X-Forwarded-For with 2 proxies
x_proto=2, # Trust X-Forwarded-Proto (http/https)
x_host=2, # Trust X-Forwarded-Host
x_prefix=2 # Trust X-Forwarded-Prefix
)
cookie_secure = True
cookie_samesite = 'None'
preferred_scheme = 'https'
else:
# Mode: Direct IP:port access (no proxy)
logger.info("=" * 60)
logger.info(" ACCESS MODE: Direct IP:Port (ENABLE_PROXY=false)")
logger.info("=" * 60)
logger.info(" Configuration:")
logger.info(" - ProxyFix middleware: DISABLED")
logger.info(" - Cookie security: HTTP allowed (Secure=False)")
logger.info(" - SameSite policy: Lax (standard mode)")
logger.info(" - URL scheme: http://")
logger.info(" - Access via: http://your-ip:5172")
logger.info("=" * 60)
# Don't apply ProxyFix for direct access
cookie_secure = False
cookie_samesite = 'Lax'
preferred_scheme = 'http'
# Initialize database on startup
malias_w.init_database()
# Session configuration - dynamic based on proxy mode
app.config.update(
PERMANENT_SESSION_LIFETIME=timedelta(hours=24),
SESSION_COOKIE_NAME='malias_session',
SESSION_COOKIE_SECURE=cookie_secure,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE=cookie_samesite,
SESSION_COOKIE_PATH='/',
SESSION_COOKIE_DOMAIN=None,
SESSION_REFRESH_EACH_REQUEST=True,
PREFERRED_URL_SCHEME=preferred_scheme
)
@app.after_request
def after_request(response):
"""Process the response before it's sent"""
if ENABLE_PROXY:
# Set CORS headers for proxy mode (needed for Authelia/Zoraxy)
response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')
response.headers['Access-Control-Allow-Credentials'] = 'true'
# Cache control (applies to both modes)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
def get_authelia_user():
"""Helper to get authenticated user from Authelia headers or session cookie"""
# Check several possible header variations (ORDER MATTERS - most specific first!)
auth_headers = [
'X-Authelia-Username', # Authelia standard header (used by Zoraxy)
'Remote-User', # Common standard
'X-Remote-User',
'X-Forwarded-User',
'REMOTE_USER',
'Http-Remote-User',
'Http-X-Remote-User',
'X-Authenticated-User'
]
for header in auth_headers:
user = request.headers.get(header)
if user:
logger.info(f"✅ Authelia user detected via header '{header}': {user}")
return user
# Check Zoraxy forwarded headers (sometimes encoded differently)
if 'X-Forwarded-Headers' in request.headers:
try:
# Some reverse proxies encode headers as JSON
fwd_headers = json.loads(request.headers.get('X-Forwarded-Headers'))
for header in auth_headers:
if header in fwd_headers:
user = fwd_headers[header]
logger.info(f"✅ Authelia user detected via forwarded headers - {header}: {user}")
return user
except:
pass
# WORKAROUND: If Authelia session cookie exists and we're coming from auth.rune.pm,
# assume user is authenticated (Authelia not forwarding headers properly)
if ENABLE_PROXY and 'authelia_session' in request.cookies:
referer = request.headers.get('Referer', '')
if 'auth.rune.pm' in referer or request.headers.get('X-Forwarded-Proto') == 'https':
# Valid Authelia session exists - assume authenticated
# Use a generic identifier since we don't have the actual username
pseudo_user = f"authelia_user_{request.cookies.get('authelia_session')[:8]}"
logger.info(f"✅ Authelia authentication detected via session cookie (headers not forwarded)")
logger.info(f" Using pseudo-user identifier: {pseudo_user}")
logger.info(f" NOTE: Configure Authelia to forward Remote-User header for proper username")
return pseudo_user
# Log when no Authelia user found (for debugging)
if ENABLE_PROXY:
logger.debug("⚠️ No Authelia headers found in request")
logger.debug(f"Available headers: {list(request.headers.keys())}")
return None
def login_required(f):
"""Decorator to require login for routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Auto-login with Authelia (only when ENABLE_PROXY=true)
if ENABLE_PROXY:
authelia_user = get_authelia_user()
# If Authelia authenticated the user, auto-login
if authelia_user:
if not session.get('logged_in') or session.get('authelia_user') != authelia_user:
logger.info(f"🔐 Auto-login via Authelia in API route: {authelia_user}")
session.clear()
session.permanent = True
session['logged_in'] = True
session['authelia_user'] = authelia_user
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'authelia'
session.modified = True
# Store additional info (check both Remote-* and X-Authelia-* headers)
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
request.headers.get('Remote-Email', ''))
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
request.headers.get('Remote-Name', ''))
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
request.headers.get('Remote-Groups', ''))
logger.info(f"✅ Auto-login in API route: {authelia_user}")
return f(*args, **kwargs)
# Regular session check (when ENABLE_PROXY=false or no Authelia headers)
if not session.get('logged_in'):
return jsonify({'status': 'error', 'message': 'Not authenticated', 'redirect': '/login'}), 401
logger.warning("Access denied: User not authenticated")
if request.is_json:
return jsonify({'status': 'error', 'message': 'Not authenticated', 'redirect': '/login'}), 401
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login page"""
"""Login page or JSON login endpoint"""
# Validate session mode matches current proxy setting
if session.get('logged_in'):
session_mode = session.get('auth_method', 'unknown')
# Clear session if mode mismatch
if ENABLE_PROXY and session_mode == 'local':
logger.info("⚠️ Session mode mismatch: Clearing local session (proxy mode enabled)")
session.clear()
elif not ENABLE_PROXY and session_mode == 'authelia':
logger.info("⚠️ Session mode mismatch: Clearing authelia session (proxy mode disabled)")
session.clear()
# Auto-login when ENABLE_PROXY=true and Authelia headers are present
if ENABLE_PROXY:
authelia_user = get_authelia_user()
if authelia_user:
# User authenticated by Authelia - auto-login
if not session.get('logged_in'):
logger.info(f"🔐 Auto-login: User '{authelia_user}' authenticated by Authelia")
session.clear()
session.permanent = True
session['logged_in'] = True
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'authelia'
session['authelia_user'] = authelia_user
session.modified = True
# Get additional Authelia info (check both Remote-* and X-Authelia-* headers)
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
request.headers.get('Remote-Email', ''))
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
request.headers.get('Remote-Name', ''))
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
request.headers.get('Remote-Groups', ''))
logger.info(f"✅ Auto-login successful: {authelia_user} ({session.get('remote_email', 'no email')})")
# Already logged in via Authelia - redirect to main page
return redirect(url_for('index'))
else:
# ENABLE_PROXY=true but no Authelia headers found
logger.warning("⚠️ ENABLE_PROXY=true but no Authelia headers detected!")
logger.warning(" Make sure your reverse proxy forwards authentication headers")
logger.warning(f" Available headers: {list(request.headers.keys())}")
# Handle form submission for local authentication (only when ENABLE_PROXY=false)
if request.method == 'POST':
password = request.json.get('password', '')
logger.info("Login attempt with password (redacted)")
if malias_w.verify_password(password):
logger.info("Login successful with local password")
session.clear()
session.permanent = True
session['logged_in'] = True
return jsonify({'status': 'success', 'message': 'Login successful'})
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'local'
session.modified = True
# Return JSON response
response = jsonify({'status': 'success', 'message': 'Login successful'})
return response
else:
logger.warning("Login failed: Invalid password")
return jsonify({'status': 'error', 'message': 'Invalid password'})
# Check if already logged in
# If already logged in, redirect to index
if session.get('logged_in'):
return redirect(url_for('index'))
# Check cookies and client IP for troubleshooting
if app.debug:
logger.info(f"Cookies: {request.cookies}")
logger.info(f"Client IP: {request.remote_addr}")
logger.info(f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}")
# Log all headers to see what's coming from Authelia
logger.info(f"All headers: {dict(request.headers)}")
# Show login form
return render_template('login.html')
@app.route('/logout')
def logout():
"""Logout"""
session.pop('logged_in', None)
return redirect(url_for('login'))
# Get auth method before clearing session
auth_method = session.get('auth_method')
authelia_user = session.get('authelia_user')
# Clear local session
session.clear()
# If user was authenticated via Authelia, redirect to app login (not Authelia logout)
# This keeps the Authelia session active for other apps
if ENABLE_PROXY and (auth_method == 'authelia' or authelia_user):
logger.info(f"Logout for Authelia user - redirecting to app login page")
# Just redirect back to login page - Authelia session stays active
response = redirect(url_for('login'))
response.set_cookie(app.config['SESSION_COOKIE_NAME'], '', expires=0)
return response
# Default case: redirect to login page
response = redirect(url_for('login'))
# Clear cookie by setting expired date
response.set_cookie(app.config['SESSION_COOKIE_NAME'], '', expires=0)
return response
@app.route('/')
def index():
"""Main page - requires login"""
# Auto-login with Authelia (only when ENABLE_PROXY=true)
if ENABLE_PROXY:
authelia_user = get_authelia_user()
if authelia_user and not session.get('logged_in'):
# Auto-login for users authenticated by Authelia
logger.info(f"🔐 Auto-login via Authelia for user: {authelia_user}")
session.clear()
session.permanent = True
session['logged_in'] = True
session['authelia_user'] = authelia_user
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'authelia'
session.modified = True
# Store additional Authelia info (check both Remote-* and X-Authelia-* headers)
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
request.headers.get('Remote-Email', ''))
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
request.headers.get('Remote-Name', ''))
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
request.headers.get('Remote-Groups', ''))
logger.info(f"✅ Auto-login successful: {authelia_user} ({session.get('remote_email', 'no email')})")
return render_template('index.html')
# Check if logged in
if not session.get('logged_in'):
return redirect(url_for('login'))
# Debug logging
if app.debug and session.get('logged_in'):
logger.info(f"User authenticated with method: {session.get('auth_method', 'unknown')}")
# Show main page
return render_template('index.html')
@app.route('/list_aliases', methods=['POST'])
@@ -63,6 +362,7 @@ def list_aliases():
result = malias_w.get_all_aliases(page=page, per_page=20)
return jsonify({'status': 'success', 'data': result})
except Exception as e:
logger.exception("Error listing aliases")
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/sync_aliases', methods=['POST'])
@@ -74,6 +374,7 @@ def sync_aliases():
result = f"Aliases synchronized successfully! Added {count} new aliases to local DB."
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error syncing aliases")
return jsonify({'status': 'error', 'message': f"Error syncing: {str(e)}"})
@app.route('/get_domains', methods=['POST'])
@@ -86,6 +387,7 @@ def get_domains():
result = f"Domains: {', '.join(domain_list)}"
return jsonify({'status': 'success', 'message': result, 'domains': domain_list})
except Exception as e:
logger.exception("Error getting domains")
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/create_alias', methods=['POST'])
@@ -104,6 +406,7 @@ def create_alias():
result = f"Alias {alias} created successfully for {goto}"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error creating alias")
return jsonify({'status': 'error', 'message': f"Error creating alias: {str(e)}"})
@app.route('/delete_alias', methods=['POST'])
@@ -121,8 +424,64 @@ def delete_alias_route():
result = f"Alias {alias} deleted successfully"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error deleting alias")
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:
logger.exception("Error deleting aliases bulk")
return jsonify({'status': 'error', 'message': f"Error deleting aliases: {str(e)}"})
@app.route('/create_timed_alias', methods=['POST'])
@login_required
def create_timed_alias():
@@ -139,6 +498,7 @@ def create_timed_alias():
result = f"Timed alias created for {username} on domain {domain}"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error creating timed alias")
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/search', methods=['POST'])
@@ -161,6 +521,7 @@ def search():
return jsonify({'status': 'success', 'message': message, 'query': query})
except Exception as e:
logger.exception("Error searching aliases")
return jsonify({'status': 'error', 'message': f"Search error: {str(e)}"})
@app.route('/config', methods=['GET', 'POST'])
@@ -206,7 +567,230 @@ def change_password_route():
malias_w.change_password(old_password, new_password)
return jsonify({'status': 'success', 'message': 'Password changed successfully'})
except Exception as e:
logger.exception("Error changing password")
return jsonify({'status': 'error', 'message': str(e)})
# Add a health check endpoint for proxies
@app.route('/health')
def health_check():
"""Health check endpoint for proxies"""
return jsonify({
'status': 'ok',
'authenticated': session.get('logged_in', False),
'authelia_user': session.get('authelia_user', None),
'auth_method': session.get('auth_method'),
'version': '1.0.2',
'cookies': {k: '***' for k in request.cookies.keys()}, # Only show cookie names
'authelia_headers_present': get_authelia_user() is not None,
'zoraxy_headers_present': 'X-Forwarded-Server' in request.headers
})
# Add a debugging endpoint
@app.route('/debug')
def debug_info():
"""Show debug information about the current request"""
# Always allow this in production to help with troubleshooting
debug_data = {
'headers': dict(request.headers),
'cookies': {k: '***' for k in request.cookies.keys()}, # Don't expose cookie values
'session': {k: ('***' if k in ['user_token', 'csrf_token'] else v) for k, v in session.items()} if session else {},
'remote_addr': request.remote_addr,
'scheme': request.scheme,
'host': request.host,
'path': request.path,
'is_secure': request.is_secure,
'x_forwarded_for': request.headers.get('X-Forwarded-For'),
'x_forwarded_proto': request.headers.get('X-Forwarded-Proto'),
'x_forwarded_host': request.headers.get('X-Forwarded-Host'),
'x_forwarded_prefix': request.headers.get('X-Forwarded-Prefix'),
'remote_user': get_authelia_user(),
}
# Additional server information
debug_data['server_info'] = {
'cookie_settings': {
'SESSION_COOKIE_NAME': app.config['SESSION_COOKIE_NAME'],
'SESSION_COOKIE_SECURE': app.config['SESSION_COOKIE_SECURE'],
'SESSION_COOKIE_HTTPONLY': app.config['SESSION_COOKIE_HTTPONLY'],
'SESSION_COOKIE_SAMESITE': app.config['SESSION_COOKIE_SAMESITE'],
'SESSION_COOKIE_PATH': app.config['SESSION_COOKIE_PATH'],
},
'app_config': {
'DEBUG': app.debug,
'PREFERRED_URL_SCHEME': app.config['PREFERRED_URL_SCHEME'],
'ENABLE_PROXY': ENABLE_PROXY
}
}
response = jsonify(debug_data)
response.headers['Cache-Control'] = 'no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Dedicated endpoint for header diagnostics
@app.route('/headers')
def show_headers():
"""Show all request headers - useful for debugging proxies"""
# Log headers to help diagnose issues with Zoraxy/Authelia
logger.info(f"Headers endpoint: All headers received: {dict(request.headers)}")
return jsonify({
'headers': dict(request.headers),
'remote_addr': request.remote_addr,
'authelia_user': get_authelia_user()
})
# Self-test endpoint for cookies
@app.route('/cookie-test')
def cookie_test():
"""Test cookie handling"""
# Clear any existing cookie
resp = make_response(jsonify({'status': 'ok', 'message': 'Cookie set test'}))
# Set a test cookie with the same attributes as session
resp.set_cookie(
'malias_test_cookie',
'test-value',
max_age=3600,
secure=app.config['SESSION_COOKIE_SECURE'],
httponly=app.config['SESSION_COOKIE_HTTPONLY'],
samesite='None',
path=app.config['SESSION_COOKIE_PATH']
)
return resp
# Endpoint to check if test cookie is set
@app.route('/cookie-check')
def cookie_check():
"""Check if test cookie was properly set"""
test_cookie = request.cookies.get('malias_test_cookie')
return jsonify({
'test_cookie_present': test_cookie is not None,
'test_cookie_value': test_cookie if test_cookie else None,
'all_cookies': {k: '***' for k in request.cookies.keys()}
})
# New endpoint to test Zoraxy auth configuration
@app.route('/authelia-test')
def authelia_test():
"""Test if Authelia headers are correctly passed through Zoraxy"""
all_headers = dict(request.headers)
authelia_headers = {}
# Check for common Authelia-related headers
auth_related_headers = [
'Remote-User', 'X-Remote-User', 'Remote-Groups', 'X-Remote-Groups',
'Remote-Name', 'X-Remote-Name', 'Remote-Email', 'X-Remote-Email',
'X-Authelia-URL', 'X-Original-URL', 'X-Forwarded-Proto'
]
for header in auth_related_headers:
if header.lower() in [h.lower() for h in all_headers.keys()]:
for actual_header in all_headers.keys():
if header.lower() == actual_header.lower():
authelia_headers[actual_header] = all_headers[actual_header]
# Check for auth cookies
auth_cookies = {}
for cookie_name in request.cookies:
if 'auth' in cookie_name.lower():
auth_cookies[cookie_name] = '***' # Hide actual value
return jsonify({
'request_host': request.host,
'authelia_user_detected': get_authelia_user() is not None,
'authelia_user': get_authelia_user(),
'authelia_headers': authelia_headers,
'auth_cookies': auth_cookies,
'all_headers_count': len(all_headers),
'zoraxy_detected': any('zoraxy' in h.lower() for h in all_headers.keys()) or 'X-Forwarded-Server' in all_headers,
'host_header': request.headers.get('Host'),
'referer': request.headers.get('Referer'),
})
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')
# Set log level based on debug mode
logger.setLevel(logging.DEBUG if debug_mode else logging.INFO)
# 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()
logger.info(f"Starting Mailcow Alias Manager - Debug: {debug_mode}, Host: {host}, Port: {port}")
app.run(debug=debug_mode, host=host, port=port)

View File

@@ -1,3 +1,20 @@
# 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
#
# Reverse Proxy Support:
# ----------------------
# This application is configured to work behind reverse proxies (Nginx, Traefik, Zoraxy, Authelia, etc.)
# The ProxyFix middleware automatically handles X-Forwarded-* headers for HTTPS detection
# No additional configuration needed for most standard proxy setups
services:
mailcow-alias-manager:
image: gitlab.pm/rune/malias-web:latest
@@ -8,4 +25,42 @@ services:
- ./data:/app/data
restart: unless-stopped
environment:
- TZ=Europe/Oslo
- 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
# Secret key for sessions - CRITICAL for multi-worker Gunicorn!
# All workers must use the SAME secret key to decode session cookies
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
# IMPORATNT! The key below is just an example. GENERATE YOUR OWN KEY WITH COMMAND ABOVE!
- SECRET_KEY=dca6920115b8bbc13c346c75d668a49849590c77d9baaf903296582f24ff816a
# Reverse Proxy Mode
# ------------------
# Set to 'true' when accessing through reverse proxy (Authelia, Zoraxy, Nginx, etc.)
# Set to 'false' for direct IP:port access (e.g., http://192.168.1.100:5172)
#
# ENABLE_PROXY=true (Proxy Mode):
# - ProxyFix middleware enabled (handles X-Forwarded-* headers)
# - Secure cookies required (HTTPS only)
# - SameSite=None (allows cross-origin authentication)
# - Access via: https://alias.yourdomain.com
#
# ENABLE_PROXY=false (Direct Mode) - DEFAULT:
# - ProxyFix middleware disabled
# - Standard cookies (HTTP allowed)
# - SameSite=Lax (standard browser behavior)
# - Access via: http://your-server-ip:5172
#
- ENABLE_PROXY=false

29
docker-entrypoint.sh Executable file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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

103
reset_password.py Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Password Reset Script for Mailcow Alias Manager
Usage: python3 reset_password.py [new_password]
"""
import sys
import sqlite3
import bcrypt
from pathlib import Path
# Database location
project_dir = Path(__file__).parent
filepath = project_dir.joinpath('data')
database = filepath.joinpath('malias2.db')
def reset_password(new_password):
"""Reset the admin password"""
if not database.exists():
print(f"❌ Error: Database not found at {database}")
print(" Make sure the application has been run at least once to create the database.")
return False
try:
# Hash the new password
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt())
# Update database
conn = sqlite3.connect(database)
cursor = conn.cursor()
# Check if auth table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='auth'")
if not cursor.fetchone():
print("❌ Error: Auth table not found in database")
conn.close()
return False
# Update password
cursor.execute('UPDATE auth SET password_hash = ? WHERE id = 0', (password_hash.decode('utf-8'),))
# Verify update
if cursor.rowcount == 0:
print("❌ Error: No password record found. Creating new one...")
cursor.execute('INSERT INTO auth VALUES (?, ?)', (0, password_hash.decode('utf-8')))
conn.commit()
conn.close()
print("=" * 60)
print(" ✅ Password Reset Successful!")
print("=" * 60)
print(f" New password: {new_password}")
print(f" Database: {database}")
print("=" * 60)
print()
print("You can now login with the new password.")
print()
return True
except Exception as e:
print(f"❌ Error resetting password: {e}")
return False
def main():
print()
print("=" * 60)
print(" Mailcow Alias Manager - Password Reset")
print("=" * 60)
print()
# Get new password
if len(sys.argv) > 1:
new_password = sys.argv[1]
else:
print("Enter new password (or press Ctrl+C to cancel):")
new_password = input("New password: ").strip()
if not new_password:
print("❌ Error: Password cannot be empty")
return 1
if len(new_password) < 6:
print("⚠️ Warning: Password is less than 6 characters")
response = input("Continue anyway? (y/n): ").strip().lower()
if response != 'y':
print("Password reset cancelled.")
return 0
print()
if reset_password(new_password):
return 0
else:
return 1
if __name__ == '__main__':
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n\nPassword reset cancelled.")
sys.exit(0)

View File

@@ -454,6 +454,93 @@
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 */
@@ -470,6 +557,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 */
@@ -486,6 +592,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;
}
</style>
</head>
@@ -501,7 +748,7 @@
<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="openDeleteAliasesModal()">Delete Aliases</button>
<button class="btn" onclick="openTimedAlias()">Create Timed Alias</button>
<button class="btn btn-config" onclick="openConfig()">Configuration</button>
</div>
@@ -509,7 +756,7 @@
<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...">
<input type="text" class="search-input" id="searchInput" placeholder="Start typing alias address (e.g., power...)">
<button class="btn btn-primary" onclick="performSearch()">Search</button>
</div>
</div>
@@ -578,17 +825,53 @@
</div>
</div>
<!-- Delete Alias Modal -->
<!-- Delete Aliases Modal -->
<div class="config-modal" id="deleteAliasModal">
<div class="config-content">
<h2>Delete Alias</h2>
<div class="config-content" style="max-width: 800px;">
<h2>Delete Aliases</h2>
<!-- Search Section -->
<div class="form-group">
<label for="deleteAliasEmail">Alias Email to Delete</label>
<input type="text" id="deleteAliasEmail" placeholder="alias@example.com">
<label for="deleteSearchInput">Search for aliases to delete</label>
<div class="search-container">
<input type="text" class="search-input" id="deleteSearchInput" placeholder="Start typing alias address (e.g., power...)">
<button class="btn btn-primary" onclick="searchAliasesForDeletion()">Search</button>
</div>
</div>
<!-- Results Section (initially hidden) -->
<div id="deleteResultsSection" style="display: none; margin-top: 20px;">
<div id="deleteResultsInfo" style="margin-bottom: 10px; color: #4CAF50;"></div>
<div id="deleteResultsTable" style="max-height: 400px; overflow-y: auto;"></div>
<!-- Bulk Selection Controls -->
<div class="bulk-controls" style="display: flex; gap: 10px; margin: 15px 0; justify-content: center;">
<button class="btn" onclick="selectAllDeleteAliases()">Select All</button>
<button class="btn" onclick="deselectAllDeleteAliases()">Deselect All</button>
</div>
</div>
<!-- Action Buttons -->
<div class="modal-buttons">
<button class="btn" onclick="closeDeleteAlias()">Cancel</button>
<button class="btn btn-primary" onclick="deleteAlias()">Delete</button>
<button class="btn" onclick="closeDeleteAliasesModal()">Cancel</button>
<button class="btn btn-primary" id="deleteSelectedBtn" onclick="confirmDeleteSelected()" disabled>
Delete Selected (0)
</button>
</div>
</div>
</div>
<!-- Confirmation Dialog -->
<div class="config-modal" id="deleteConfirmModal">
<div class="config-content">
<h2 style="color: #f44336;">⚠️ Confirm Deletion</h2>
<div id="deleteConfirmContent"></div>
<p style="color: #f44336; margin-top: 15px; font-weight: bold;">This action cannot be undone.</p>
<div class="modal-buttons">
<button class="btn" onclick="closeDeleteConfirm()">Cancel</button>
<button class="btn" style="background-color: #d32f2f; border-color: #d32f2f;" onclick="executeDeleteSelected()">
Yes, Delete All
</button>
</div>
</div>
</div>
@@ -672,6 +955,7 @@
try {
const response = await fetch('/list_aliases', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -700,6 +984,7 @@
try {
const response = await fetch(`/${actionName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
@@ -725,6 +1010,7 @@
try {
const response = await fetch('/search', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -749,7 +1035,9 @@
async function openConfig() {
try {
const response = await fetch('/config');
const response = await fetch('/config', {
credentials: 'include'
});
const config = await response.json();
document.getElementById('mailcowServer').value = config.mailcow_server || '';
@@ -777,6 +1065,7 @@
try {
const response = await fetch('/config', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -811,6 +1100,7 @@
try {
const response = await fetch('/change_password', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -858,6 +1148,7 @@
try {
const response = await fetch('/create_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -876,43 +1167,305 @@
}
}
// Delete Alias Modal Functions
function openDeleteAlias() {
// Delete Aliases Modal Functions
let selectedAliases = new Set();
let currentDeleteResults = [];
function openDeleteAliasesModal() {
document.getElementById('deleteAliasModal').classList.add('active');
document.getElementById('deleteSearchInput').value = '';
document.getElementById('deleteResultsSection').style.display = 'none';
selectedAliases.clear();
currentDeleteResults = [];
updateDeleteButtonText();
}
function closeDeleteAlias() {
function closeDeleteAliasesModal() {
document.getElementById('deleteAliasModal').classList.remove('active');
selectedAliases.clear();
currentDeleteResults = [];
}
async function deleteAlias() {
const alias = document.getElementById('deleteAliasEmail').value;
if (!alias) {
showResult('Alias email is required', false);
async function searchAliasesForDeletion() {
const query = document.getElementById('deleteSearchInput').value;
if (!query.trim()) {
showResult('Please enter a search query', false);
return;
}
try {
const response = await fetch('/search', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: query })
});
if (checkAuth(response)) return;
const data = await response.json();
if (data.status === 'success') {
// Parse search results
const results = [];
const lines = data.message.split('\n');
for (const line of lines) {
if (line.includes('→')) {
const parts = line.split('→').map(p => p.trim());
if (parts.length === 2) {
results.push({
alias: parts[0],
goto: parts[1]
});
}
}
}
// Limit to 100 results
const limited = results.slice(0, 100);
const hasMore = results.length > 100;
currentDeleteResults = limited;
renderDeleteResults(limited, hasMore, results.length);
} else {
document.getElementById('deleteResultsSection').style.display = 'none';
showResult(data.message, false);
}
} catch (error) {
showResult(`Search error: ${error.message}`, false);
}
}
function renderDeleteResults(results, hasMore, totalCount) {
if (results.length === 0) {
document.getElementById('deleteResultsSection').style.display = 'none';
showResult('No aliases found matching your search', false);
return;
}
document.getElementById('deleteResultsSection').style.display = 'block';
// Info text
let infoText = `Found ${results.length} alias(es)`;
if (hasMore) {
infoText += ` (showing first 100 of ${totalCount})`;
}
document.getElementById('deleteResultsInfo').innerHTML = infoText;
// Warning for too many results
let warningHtml = '';
if (hasMore) {
warningHtml = `<div class="results-warning">⚠️ Showing first 100 of ${totalCount} results. Please refine your search for more specific results.</div>`;
}
// Build table
let html = warningHtml + `
<table class="delete-results-table">
<thead>
<tr>
<th>Select</th>
<th>Alias</th>
<th>Goes To</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
`;
for (let i = 0; i < results.length; i++) {
const result = results[i];
const isSelected = selectedAliases.has(result.alias);
html += `
<tr class="delete-table-row ${isSelected ? 'selected' : ''}" id="delete-row-${i}">
<td>
<input type="checkbox"
class="delete-checkbox"
id="checkbox-${i}"
${isSelected ? 'checked' : ''}
onchange="toggleAliasSelection('${escapeHtml(result.alias)}', ${i})">
</td>
<td>${escapeHtml(result.alias)}</td>
<td>${escapeHtml(result.goto)}</td>
<td>
<span class="delete-icon" onclick="deleteSingleAliasQuick('${escapeHtml(result.alias)}', ${i})">🗑️</span>
</td>
</tr>
`;
}
html += '</tbody></table>';
document.getElementById('deleteResultsTable').innerHTML = html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function toggleAliasSelection(alias, rowIndex) {
const row = document.getElementById(`delete-row-${rowIndex}`);
const checkbox = document.getElementById(`checkbox-${rowIndex}`);
if (selectedAliases.has(alias)) {
selectedAliases.delete(alias);
row.classList.remove('selected');
} else {
selectedAliases.add(alias);
row.classList.add('selected');
}
updateDeleteButtonText();
}
function selectAllDeleteAliases() {
selectedAliases.clear();
for (const result of currentDeleteResults) {
selectedAliases.add(result.alias);
}
// Update UI
const checkboxes = document.querySelectorAll('.delete-checkbox');
const rows = document.querySelectorAll('.delete-table-row');
checkboxes.forEach(cb => cb.checked = true);
rows.forEach(row => row.classList.add('selected'));
updateDeleteButtonText();
}
function deselectAllDeleteAliases() {
selectedAliases.clear();
// Update UI
const checkboxes = document.querySelectorAll('.delete-checkbox');
const rows = document.querySelectorAll('.delete-table-row');
checkboxes.forEach(cb => cb.checked = false);
rows.forEach(row => row.classList.remove('selected'));
updateDeleteButtonText();
}
function updateDeleteButtonText() {
const btn = document.getElementById('deleteSelectedBtn');
const count = selectedAliases.size;
btn.textContent = `Delete Selected (${count})`;
btn.disabled = count === 0;
}
function confirmDeleteSelected() {
if (selectedAliases.size === 0) return;
const aliasArray = Array.from(selectedAliases);
let confirmHtml = `
<p>You are about to delete <strong>${aliasArray.length}</strong> alias(es):</p>
<div class="delete-confirm-list">
<ul>
`;
for (const alias of aliasArray) {
confirmHtml += `<li>${escapeHtml(alias)}</li>`;
}
confirmHtml += '</ul></div>';
document.getElementById('deleteConfirmContent').innerHTML = confirmHtml;
document.getElementById('deleteConfirmModal').classList.add('active');
}
function closeDeleteConfirm() {
document.getElementById('deleteConfirmModal').classList.remove('active');
}
async function executeDeleteSelected() {
const aliasArray = Array.from(selectedAliases);
if (aliasArray.length === 0) return;
closeDeleteConfirm();
try {
const response = await fetch('/delete_aliases_bulk', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ aliases: aliasArray })
});
if (checkAuth(response)) return;
const data = await response.json();
showResult(data.message, data.status === 'success' || data.status === 'partial');
// Refresh search results
if (data.status === 'success' || data.status === 'partial') {
// Clear selection
selectedAliases.clear();
updateDeleteButtonText();
// Re-run search to show updated results
await searchAliasesForDeletion();
}
} catch (error) {
showResult(`Error deleting aliases: ${error.message}`, false);
}
}
async function deleteSingleAliasQuick(alias, rowIndex) {
if (!confirm(`Delete ${alias}?`)) {
return;
}
try {
const response = await fetch('/delete_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ alias: alias })
});
if (checkAuth(response)) return;
const data = await response.json();
showResult(data.message, data.status === 'success');
if (data.status === 'success') {
document.getElementById('deleteAliasEmail').value = '';
closeDeleteAlias();
// Remove from selected if it was selected
selectedAliases.delete(alias);
// Remove from current results
currentDeleteResults = currentDeleteResults.filter(r => r.alias !== alias);
// Re-render table
renderDeleteResults(currentDeleteResults, false, currentDeleteResults.length);
showResult(`Alias ${alias} deleted successfully`, true);
} else {
showResult(data.message, false);
}
} catch (error) {
showResult(`Error deleting alias: ${error.message}`, false);
}
}
// Allow search on Enter key in delete modal
document.addEventListener('DOMContentLoaded', function() {
const deleteSearchInput = document.getElementById('deleteSearchInput');
if (deleteSearchInput) {
deleteSearchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchAliasesForDeletion();
}
});
}
});
// Timed Alias Modal Functions
function openTimedAlias() {
document.getElementById('timedAliasModal').classList.add('active');
@@ -934,6 +1487,7 @@
try {
const response = await fetch('/create_timed_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -960,7 +1514,10 @@
if (e.target === this) closeCreateAlias();
});
document.getElementById('deleteAliasModal').addEventListener('click', function(e) {
if (e.target === this) closeDeleteAlias();
if (e.target === this) closeDeleteAliasesModal();
});
document.getElementById('deleteConfirmModal').addEventListener('click', function(e) {
if (e.target === this) closeDeleteConfirm();
});
document.getElementById('timedAliasModal').addEventListener('click', function(e) {
if (e.target === this) closeTimedAlias();

View File

@@ -192,6 +192,7 @@
headers: {
'Content-Type': 'application/json'
},
credentials: 'include', // CRITICAL: Include cookies in request
body: JSON.stringify({ password: password })
});