Compare commits

..

23 Commits

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
203d5e0575 Small changes, added license 2026-01-13 14:24:19 +01:00
14 changed files with 1918 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

@@ -134,3 +134,7 @@ 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
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

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"]

21
LICENSE Normal file
View File

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

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:
@@ -295,3 +320,7 @@ For manual Python installation:
- 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`
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

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

@@ -16,6 +16,12 @@
box-sizing: border-box;
}
.footer {
font-size: 16px !important;
color: #707170FF;
text-align: center;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #1a1a1a;
@@ -273,6 +279,13 @@
justify-content: flex-end;
}
.footer {
font-size: 12px;
color: #707170FF;
align: center;
padding-top: 20%;
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
body {
@@ -440,6 +453,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 +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 */
@@ -472,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>
@@ -487,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>
@@ -495,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>
@@ -564,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>
@@ -658,6 +955,7 @@
try {
const response = await fetch('/list_aliases', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -686,6 +984,7 @@
try {
const response = await fetch(`/${actionName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
@@ -711,6 +1010,7 @@
try {
const response = await fetch('/search', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -735,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 || '';
@@ -763,6 +1065,7 @@
try {
const response = await fetch('/config', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -797,6 +1100,7 @@
try {
const response = await fetch('/change_password', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -844,6 +1148,7 @@
try {
const response = await fetch('/create_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -862,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');
@@ -920,6 +1487,7 @@
try {
const response = await fetch('/create_timed_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -946,11 +1514,18 @@
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();
});
</script>
</body>
<div class="footer">
<p style="padding-top:2%">&copy; <a href="https://opensource.org/license/mit">MIT</a> 2026 Rune Olsen <a href="https://blog.rune.pm">Blog</a> &#8212; <a href="https://gitlab.pm/rune/malias-web">Source Code</a></p>
</div>
</html>

View File

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